"""
Tool Manager for Helion Agent
Implements OpenAI-compatible function calling for DeepSeek, Mistral, Grok
"""

import os
import asyncio
import uuid
from agent_tools_config import get_agent_tools, is_tool_allowed
import json
import logging
import hashlib
import base64
import csv
import re
import tempfile
import subprocess
import httpx
from typing import Dict, List, Any, Optional
from dataclasses import dataclass
from io import BytesIO, StringIO
from pathlib import Path, PurePath
import xml.etree.ElementTree as ET
from xml.sax.saxutils import escape as xml_escape
from zipfile import ZIP_DEFLATED, ZipFile

logger = logging.getLogger(__name__)

# Tool definitions in OpenAI function calling format
# ORDER MATTERS: Memory/Graph tools first, then web search as fallback
TOOL_DEFINITIONS = [
    # PRIORITY 1: Internal knowledge sources (use FIRST)
    {
        "type": "function",
        "function": {
            "name": "memory_search",
            "description": "🔍 ПЕРШИЙ КРОК для пошуку! Шукає в моїй пам'яті: збережені факти, документи, розмови. ЗАВЖДИ використовуй спочатку перед web_search!",
            "parameters": {
                "type": "object",
                "properties": {
                    "query": {
                        "type": "string",
                        "description": "Що шукати в пам'яті"
                    }
                },
                "required": ["query"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "graph_query",
            "description": "🔍 Пошук в Knowledge Graph - зв'язки між проєктами, людьми, темами Energy Union. Використовуй для питань про проєкти, партнерів, технології.",
            "parameters": {
                "type": "object",
                "properties": {
                    "query": {
                        "type": "string",
                        "description": "Що шукати (назва проєкту, людини, теми)"
                    },
                    "entity_type": {
                        "type": "string",
                        "enum": ["User", "Topic", "Project", "Fact"],
                        "description": "Тип сутності для пошуку"
                    }
                },
                "required": ["query"]
            }
        }
    },
    # PRIORITY 2: Web search (use ONLY if memory/graph don't have info)
    {
        "type": "function",
        "function": {
            "name": "web_search",
            "description": "🌐 Пошук в інтернеті. Використовуй ТІЛЬКИ якщо memory_search і graph_query не знайшли потрібної інформації!",
            "parameters": {
                "type": "object",
                "properties": {
                    "query": {
                        "type": "string",
                        "description": "Пошуковий запит"
                    },
                    "max_results": {
                        "type": "integer",
                        "description": "Максимальна кількість результатів (1-10)",
                        "default": 5
                    }
                },
                "required": ["query"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "web_extract",
            "description": "Витягнути текстовий контент з веб-сторінки за URL",
            "parameters": {
                "type": "object",
                "properties": {
                    "url": {
                        "type": "string",
                        "description": "URL сторінки для читання"
                    }
                },
                "required": ["url"]
            }
        }
    },
    # PRIORITY 3: Generation tools
    {
        "type": "function",
        "function": {
            "name": "image_generate",
            "description": "🎨 Згенерувати зображення за текстовим описом (FLUX)",
            "parameters": {
                "type": "object",
                "properties": {
                    "prompt": {
                        "type": "string",
                        "description": "Опис зображення для генерації (англійською краще)"
                    },
                    "width": {
                        "type": "integer",
                        "description": "Ширина зображення",
                        "default": 512
                    },
                    "height": {
                        "type": "integer",
                        "description": "Висота зображення",
                        "default": 512
                    }
                },
                "required": ["prompt"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "comfy_generate_image",
            "description": "🖼️ Згенерувати зображення через ComfyUI (NODE3, Stable Diffusion). Для високої якості та детальних зображень.",
            "parameters": {
                "type": "object",
                "properties": {
                    "prompt": {
                        "type": "string",
                        "description": "Детальний опис зображення (англійською)"
                    },
                    "negative_prompt": {
                        "type": "string",
                        "description": "Що НЕ включати в зображення",
                        "default": "blurry, low quality, watermark"
                    },
                    "width": {
                        "type": "integer",
                        "description": "Ширина (512, 768, 1024)",
                        "default": 512
                    },
                    "height": {
                        "type": "integer",
                        "description": "Висота (512, 768, 1024)",
                        "default": 512
                    },
                    "steps": {
                        "type": "integer",
                        "description": "Кількість кроків генерації (20-50)",
                        "default": 28
                    }
                },
                "required": ["prompt"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "comfy_generate_video",
            "description": "🎬 Згенерувати відео через ComfyUI (NODE3, LTX-2). Text-to-video для коротких кліпів.",
            "parameters": {
                "type": "object",
                "properties": {
                    "prompt": {
                        "type": "string",
                        "description": "Детальний опис відео (англійською)"
                    },
                    "seconds": {
                        "type": "integer",
                        "description": "Тривалість в секундах (2-8)",
                        "default": 4
                    },
                    "fps": {
                        "type": "integer",
                        "description": "Кадри в секунду (24-30)",
                        "default": 24
                    },
                    "steps": {
                        "type": "integer",
                        "description": "Кількість кроків генерації (20-40)",
                        "default": 30
                    }
                },
                "required": ["prompt"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "remember_fact",
            "description": "Запам'ятати важливий факт про користувача або тему",
            "parameters": {
                "type": "object",
                "properties": {
                    "fact": {
                        "type": "string",
                        "description": "Факт для запам'ятовування"
                    },
                    "about": {
                        "type": "string",
                        "description": "Про кого/що цей факт (username або тема)"
                    },
                    "category": {
                        "type": "string",
                        "enum": ["personal", "technical", "preference", "project"],
                        "description": "Категорія факту"
                    }
                },
                "required": ["fact"]
            }
        }
    },
    # PRIORITY 4: Document/Presentation tools
    {
        "type": "function",
        "function": {
            "name": "presentation_create",
            "description": "📊 Створити презентацію PowerPoint. Використовуй коли користувач просить 'створи презентацію', 'зроби презентацію', 'підготуй слайди'.",
            "parameters": {
                "type": "object",
                "properties": {
                    "title": {
                        "type": "string",
                        "description": "Назва презентації"
                    },
                    "slides": {
                        "type": "array",
                        "items": {
                            "type": "object",
                            "properties": {
                                "title": {"type": "string", "description": "Заголовок слайду"},
                                "content": {"type": "string", "description": "Контент слайду (markdown)"}
                            }
                        },
                        "description": "Масив слайдів: [{title, content}]"
                    },
                    "brand_id": {
                        "type": "string",
                        "description": "ID бренду для стилю (energyunion, greenfood, nutra)",
                        "default": "energyunion"
                    },
                    "theme_version": {
                        "type": "string",
                        "description": "Версія теми",
                        "default": "v1.0.0"
                    },
                    "language": {
                        "type": "string",
                        "enum": ["uk", "en", "ru"],
                        "description": "Мова презентації",
                        "default": "uk"
                    }
                },
                "required": ["title", "slides"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "presentation_status",
            "description": "📋 Перевірити статус створення презентації за job_id",
            "parameters": {
                "type": "object",
                "properties": {
                    "job_id": {
                        "type": "string",
                        "description": "ID завдання рендерингу"
                    }
                },
                "required": ["job_id"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "presentation_download",
            "description": "📥 Отримати посилання на готову презентацію за artifact_id",
            "parameters": {
                "type": "object",
                "properties": {
                    "artifact_id": {
                        "type": "string",
                        "description": "ID артефакту презентації"
                    },
                    "format": {
                        "type": "string",
                        "enum": ["pptx", "pdf"],
                        "description": "Формат файлу",
                        "default": "pptx"
                    }
                },
                "required": ["artifact_id"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "file_tool",
            "description": "📁 Універсальний file tool для створення та оновлення CSV/JSON/YAML/ZIP і інших форматів через action-based API.",
            "parameters": {
                "type": "object",
                "properties": {
                    "action": {
                        "type": "string",
                        "enum": [
                            "excel_create", "excel_update", "docx_create", "docx_update",
                            "pptx_create", "pptx_update",
                            "ods_create", "ods_update", "parquet_create", "parquet_update",
                            "csv_create", "csv_update", "pdf_fill", "pdf_merge", "pdf_split", "pdf_update",
                            "djvu_to_pdf", "djvu_extract_text",
                            "json_export", "yaml_export", "zip_bundle",
                            "text_create", "text_update", "markdown_create", "markdown_update",
                            "xml_export", "html_export",
                            "image_create", "image_edit", "image_convert", "image_bundle",
                            "svg_export", "svg_to_png"
                        ],
                        "description": "Дія file tool"
                    },
                    "file_name": {
                        "type": "string",
                        "description": "Назва файлу-результату"
                    },
                    "file_base64": {
                        "type": "string",
                        "description": "Вхідний файл у base64 для update-операцій"
                    },
                    "content": {
                        "description": "Контент для json/yaml export"
                    },
                    "headers": {
                        "type": "array",
                        "items": {"type": "string"},
                        "description": "Заголовки для CSV"
                    },
                    "rows": {
                        "type": "array",
                        "description": "Рядки для CSV"
                    },
                    "entries": {
                        "type": "array",
                        "description": "Елементи для zip_bundle"
                    },
                    "operation": {
                        "type": "string",
                        "enum": ["append", "replace"],
                        "description": "Режим csv_update"
                    }
                },
                "required": ["action"]
            }
        }
    },
    # PRIORITY 5: Web Scraping tools
    {
        "type": "function",
        "function": {
            "name": "crawl4ai_scrape",
            "description": "🕷️ Глибокий скрейпінг веб-сторінки через Crawl4AI. Витягує повний контент, структуровані дані, медіа. Використовуй для детального аналізу сайтів.",
            "parameters": {
                "type": "object",
                "properties": {
                    "url": {
                        "type": "string",
                        "description": "URL сторінки для скрейпінгу"
                    },
                    "extract_links": {
                        "type": "boolean",
                        "description": "Витягувати посилання зі сторінки",
                        "default": True
                    },
                    "extract_images": {
                        "type": "boolean",
                        "description": "Витягувати зображення",
                        "default": False
                    }
                },
                "required": ["url"]
            }
        }
    },
    # PRIORITY 6: TTS tools
    {
        "type": "function",
        "function": {
            "name": "tts_speak",
            "description": "🔊 Перетворити текст на аудіо (Text-to-Speech). Повертає аудіо файл. Використовуй коли користувач просить озвучити текст.",
            "parameters": {
                "type": "object",
                "properties": {
                    "text": {
                        "type": "string",
                        "description": "Текст для озвучення"
                    },
                    "language": {
                        "type": "string",
                        "enum": ["uk", "en", "ru"],
                        "description": "Мова озвучення",
                        "default": "uk"
                    }
                },
                "required": ["text"]
            }
        }
    },
    # PRIORITY 7: Market Data tools (SenpAI)
    {
        "type": "function",
        "function": {
            "name": "market_data",
            "description": "📊 Real-time ринкові дані: ціна, стакан, обсяги, 24h статистика, аналітичні фічі. Підтримувані символи (23): BTC, ETH, BNB, SOL, XRP, ADA, DOGE, AVAX, DOT, LINK, POL(MATIC), SHIB, TRX, UNI, LTC, ATOM, NEAR, ICP, FIL, APT (Binance Spot), PAXGUSDT (Gold-backed), XAUUSDT (Gold via Kraken), XAGUSDT (Silver via Kraken).",
            "parameters": {
                "type": "object",
                "properties": {
                    "symbol": {
                        "type": "string",
                        "description": "Символ пари: BTCUSDT, ETHUSDT, SOLUSDT, XAUUSDT, XAGUSDT, PAXGUSDT тощо. Підтримується 23 символи."
                    },
                    "query_type": {
                        "type": "string",
                        "enum": ["price", "features", "all", "multi"],
                        "description": "price=ціна+стакан, features=VWAP/volatility/signals, all=повний, multi=всі 23 символи одразу",
                        "default": "all"
                    }
                },
                "required": ["symbol"]
            }
        }
    },
    # PRIORITY 7b: Binance Bot Monitor (SenpAI)
    {
        "type": "function",
        "function": {
            "name": "binance_bots_top",
            "description": "🤖 Показати топ торгових ботів Binance Marketplace (Spot/Futures Grid): ROI, PNL, риск-рейтинг. Також показує результати web-пошуку по Binance ботах.",
            "parameters": {
                "type": "object",
                "properties": {
                    "grid_type": {
                        "type": "string",
                        "enum": ["SPOT", "FUTURES"],
                        "description": "Тип гриду: SPOT або FUTURES",
                        "default": "SPOT"
                    },
                    "limit": {
                        "type": "integer",
                        "description": "Кількість ботів (max 20)",
                        "default": 10
                    }
                },
                "required": []
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "binance_account_bots",
            "description": "💼 Показати стан власного Binance суб-акаунту: баланси, активні grid/algo ордери, типи дозволів (canTrade, permissions).",
            "parameters": {
                "type": "object",
                "properties": {
                    "force_refresh": {
                        "type": "boolean",
                        "description": "Примусово оновити дані з Binance API (ігноруючи кеш)",
                        "default": False
                    }
                },
                "required": []
            }
        }
    },
    # PRIORITY 8: 1OK Window Master tools
    {
        "type": "function",
        "function": {
            "name": "crm_search_client",
            "description": "Пошук клієнта в CRM за телефоном/email/ПІБ.",
            "parameters": {
                "type": "object",
                "properties": {
                    "query": {"type": "string", "description": "Телефон, email або ім'я клієнта"}
                },
                "required": ["query"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "crm_upsert_client",
            "description": "Створити або оновити клієнта в CRM.",
            "parameters": {
                "type": "object",
                "properties": {
                    "client_payload": {"type": "object", "description": "Дані клієнта"}
                },
                "required": ["client_payload"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "crm_upsert_site",
            "description": "Створити або оновити об'єкт (адресу) в CRM.",
            "parameters": {
                "type": "object",
                "properties": {
                    "site_payload": {"type": "object", "description": "Дані об'єкта/адреси"}
                },
                "required": ["site_payload"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "crm_upsert_window_unit",
            "description": "Створити або оновити віконний блок/проріз в CRM.",
            "parameters": {
                "type": "object",
                "properties": {
                    "window_payload": {"type": "object", "description": "Дані віконного блоку"}
                },
                "required": ["window_payload"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "crm_create_quote",
            "description": "Створити quote/КП в CRM.",
            "parameters": {
                "type": "object",
                "properties": {
                    "quote_payload": {"type": "object", "description": "Дані КП/розрахунку"}
                },
                "required": ["quote_payload"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "crm_update_quote",
            "description": "Оновити існуючий quote в CRM.",
            "parameters": {
                "type": "object",
                "properties": {
                    "quote_id": {"type": "string"},
                    "patch": {"type": "object"}
                },
                "required": ["quote_id", "patch"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "crm_create_job",
            "description": "Створити job (замір/монтаж/сервіс) в CRM.",
            "parameters": {
                "type": "object",
                "properties": {
                    "job_payload": {"type": "object", "description": "Дані job"}
                },
                "required": ["job_payload"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "calc_window_quote",
            "description": "Прорахунок вікон через calc-сервіс.",
            "parameters": {
                "type": "object",
                "properties": {
                    "input_payload": {"type": "object", "description": "Вхід для калькулятора"}
                },
                "required": ["input_payload"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "docs_render_quote_pdf",
            "description": "Рендер PDF комерційної пропозиції.",
            "parameters": {
                "type": "object",
                "properties": {
                    "quote_id": {"type": "string"},
                    "quote_payload": {"type": "object"}
                }
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "docs_render_invoice_pdf",
            "description": "Рендер PDF рахунку.",
            "parameters": {
                "type": "object",
                "properties": {
                    "invoice_payload": {"type": "object", "description": "Дані рахунку"}
                },
                "required": ["invoice_payload"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "schedule_propose_slots",
            "description": "Запропонувати слоти на замір/монтаж.",
            "parameters": {
                "type": "object",
                "properties": {
                    "params": {"type": "object", "description": "Параметри планування"}
                },
                "required": ["params"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "schedule_confirm_slot",
            "description": "Підтвердити обраний слот.",
            "parameters": {
                "type": "object",
                "properties": {
                    "job_id": {"type": "string"},
                    "slot": {}
                },
                "required": ["job_id", "slot"]
            }
        }
    },
    # PRIORITY 9+: Data Governance & Privacy Tool
    {
        "type": "function",
        "function": {
                    "name": "data_governance_tool",
                    "description": "🔒 Data Governance & Privacy: детермінований сканер PII/secrets/logging-ризиків і retention-політик. Actions: digest_audit (щоденний зріз+markdown), scan_repo, scan_audit, retention_check, policy. Read-only, evidence завжди маскується.",
                    "parameters": {
                        "type": "object",
                        "properties": {
                            "action": {
                                "type": "string",
                                "enum": ["digest_audit", "scan_repo", "scan_audit", "retention_check", "policy"],
                                "description": "digest_audit=щоденний зріз+markdown, scan_repo=статичний аналіз файлів, scan_audit=аналіз audit-стріму, retention_check=перевірка cleanup-механізмів, policy=поточні політики"
                            },
                            "backend": {
                                "type": "string",
                                "enum": ["auto", "jsonl", "postgres"],
                                "description": "Джерело для scan_audit/digest_audit: auto=Postgres або JSONL (default)"
                            },
                    "mode": {
                        "type": "string",
                        "enum": ["fast", "full"],
                        "description": "fast=тільки .py/.yml/.json (default), full=всі розширення"
                    },
                    "max_files": {"type": "integer", "description": "Максимум файлів для scan_repo (default 200)"},
                    "paths_include": {
                        "type": "array",
                        "items": {"type": "string"},
                        "description": "Директорії для сканування (default: services/, config/, ops/)"
                    },
                    "paths_exclude": {
                        "type": "array",
                        "items": {"type": "string"},
                        "description": "Шаблони виключень (glob)"
                    },
                    "focus": {
                        "type": "array",
                        "items": {"type": "string", "enum": ["pii", "secrets", "logging", "retention"]},
                        "description": "Категорії перевірок (default: всі)"
                    },
                    "backend": {
                        "type": "string",
                        "enum": ["jsonl", "postgres"],
                        "description": "Backend для scan_audit (default: jsonl)"
                    },
                    "time_window_hours": {"type": "integer", "description": "Вікно для scan_audit в годинах (default 24)"},
                    "max_events": {"type": "integer", "description": "Ліміт подій для scan_audit (default 50000)"}
                },
                "required": ["action"]
            }
        }
    },
    # PRIORITY 9+: Cost Analyzer Tool (FinOps)
    {
        "type": "function",
        "function": {
                    "name": "cost_analyzer_tool",
                    "description": "📊 FinOps: аналіз витрат/навантаження по tools/агентах/сервісах. Actions: digest (щоденний зріз), report (агрегація), top (топ-N), anomalies (спайки), weights (конфіг). backend=auto вибирає Postgres або JSONL.",
                    "parameters": {
                        "type": "object",
                        "properties": {
                            "action": {
                                "type": "string",
                                "enum": ["digest", "report", "top", "anomalies", "weights"],
                                "description": "digest=щоденний зріз+markdown, report=агрегація, top=топ-N, anomalies=спайки, weights=ваги"
                            },
                            "backend": {
                                "type": "string",
                                "enum": ["auto", "jsonl", "postgres"],
                                "description": "Джерело даних: auto=Postgres або JSONL (default)"
                            },
                    "time_range": {
                        "type": "object",
                        "properties": {
                            "from": {"type": "string", "description": "ISO8601 start"},
                            "to": {"type": "string", "description": "ISO8601 end"}
                        },
                        "description": "Часовий діапазон (для report)"
                    },
                    "group_by": {
                        "type": "array",
                        "items": {"type": "string", "enum": ["tool", "agent_id", "user_id", "workspace_id"]},
                        "description": "Ключі агрегації (для report)"
                    },
                    "top_n": {"type": "integer", "description": "Кількість топ елементів (default 10)"},
                    "window_hours": {"type": "integer", "description": "Вікно аналізу в годинах (для top/anomalies)"},
                    "window_minutes": {"type": "integer", "description": "Вікно пошуку спайків в хвилинах (для anomalies)"},
                    "baseline_hours": {"type": "integer", "description": "Базовий період порівняння в годинах (для anomalies)"},
                    "ratio_threshold": {"type": "number", "description": "Поріг спайку (default 3.0)"},
                    "include_failed": {"type": "boolean", "description": "Включати failed calls (default true)"},
                    "include_hourly": {"type": "boolean", "description": "Включати погодинний тренд (default false)"}
                },
                "required": ["action"]
            }
        }
    },
    # PRIORITY 9+: Dependency Scanner Tool
    {
        "type": "function",
        "function": {
            "name": "dependency_scanner_tool",
            "description": "🔒 Сканує залежності Python/Node.js на вразливості (OSV.dev), застарілі версії та license policy. Offline-friendly: використовує локальний кеш. Повертає pass/fail + структурований звіт.",
            "parameters": {
                "type": "object",
                "properties": {
                    "action": {
                        "type": "string",
                        "enum": ["scan"],
                        "description": "Дія: scan (запустити сканування)"
                    },
                    "targets": {
                        "type": "array",
                        "items": {"type": "string", "enum": ["python", "node"]},
                        "description": "Екосистеми для сканування (default: python та node)"
                    },
                    "vuln_mode": {
                        "type": "string",
                        "enum": ["online", "offline_cache"],
                        "description": "Режим перевірки вразливостей (default: offline_cache)"
                    },
                    "fail_on": {
                        "type": "array",
                        "items": {"type": "string", "enum": ["CRITICAL", "HIGH", "MEDIUM", "LOW"]},
                        "description": "Severity рівні, що блокують (default: CRITICAL, HIGH)"
                    },
                    "timeout_sec": {
                        "type": "number",
                        "description": "Таймаут в секундах (default: 40)"
                    }
                },
                "required": ["action"]
            }
        }
    },
    # PRIORITY 9+: Drift Analyzer Tool
    {
        "type": "function",
        "function": {
            "name": "drift_analyzer_tool",
            "description": "🔍 Аналіз drift між 'джерелами правди' і фактичним станом репозиторію: services (catalog vs compose), OpenAPI (spec vs code routes), NATS (inventory vs code), tools (rollout/matrix vs handlers). Повертає структурований drift report з pass/fail.",
            "parameters": {
                "type": "object",
                "properties": {
                    "action": {
                        "type": "string",
                        "enum": ["analyze"],
                        "description": "Дія: analyze (запустити повний drift аналіз)"
                    },
                    "categories": {
                        "type": "array",
                        "items": {"type": "string", "enum": ["services", "openapi", "nats", "tools"]},
                        "description": "Категорії для перевірки (default: всі)"
                    },
                    "timeout_sec": {
                        "type": "number",
                        "description": "Таймаут в секундах (default: 25)",
                        "default": 25
                    }
                },
                "required": ["action"]
            }
        }
    },
    # PRIORITY 9: Repo Tool (read-only filesystem/git access)
    # PRIORITY 9+: Alert Ingest Tool
    {
        "type": "function",
        "function": {
            "name": "alert_ingest_tool",
            "description": "🚨 Приймання, дедуплікація та управління алертами. Monitor надсилає структуровані алерти. Sofiia/oncall перетворює їх на інциденти. НЕ дає прав на incident_write.",
            "parameters": {
                "type": "object",
                "properties": {
                    "action": {
                        "type": "string",
                        "enum": ["ingest", "list", "get", "ack", "claim", "fail"],
                        "description": "Дія: ingest (Monitor), list (перегляд), get (деталі), ack (підтвердити), claim (атомарно взяти пачку для обробки), fail (відмітити як failed)"
                    },
                    "alert": {
                        "type": "object",
                        "description": "AlertEvent (для ingest)",
                        "properties": {
                            "service": {"type": "string"},
                            "env": {"type": "string", "enum": ["prod", "staging", "dev"]},
                            "severity": {"type": "string", "enum": ["P0", "P1", "P2", "P3", "INFO"]},
                            "kind": {"type": "string", "enum": ["slo_breach", "crashloop", "latency", "error_rate", "disk", "oom", "deploy", "security", "custom"]},
                            "title": {"type": "string"},
                            "summary": {"type": "string"},
                            "started_at": {"type": "string"},
                            "source": {"type": "string"},
                            "labels": {"type": "object"},
                            "metrics": {"type": "object"},
                            "evidence": {"type": "object"}
                        }
                    },
                    "dedupe_ttl_minutes": {"type": "integer", "description": "TTL для дедуплікації (default: 30)"},
                    "alert_ref": {"type": "string", "description": "Референс алерту (для get/ack)"},
                    "actor": {"type": "string", "description": "Хто підтверджує (для ack)"},
                    "note": {"type": "string", "description": "Примітка (для ack)"},
                    "service": {"type": "string", "description": "Фільтр по сервісу (для list)"},
                    "env": {"type": "string", "description": "Фільтр по env (для list)"},
                    "window_minutes": {"type": "integer", "description": "Вікно аналізу (для list, default 240)"},
                    "limit": {"type": "integer", "description": "Ліміт результатів (default 50)"},
                    "status_in": {"type": "array", "items": {"type": "string"}, "description": "Фільтр по статусах (list/claim)"},
                    "owner": {"type": "string", "description": "Ідентифікатор власника lock (для claim)"},
                    "lock_ttl_seconds": {"type": "integer", "description": "TTL блокування в секундах (для claim, default 600)"},
                    "error": {"type": "string", "description": "Опис помилки (для fail)"},
                    "retry_after_seconds": {"type": "integer", "description": "Час до повтору в секундах (для fail, default 300)"}
                },
                "required": ["action"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "incident_escalation_tool",
            "description": "⚡ Deterministic Incident Escalation Engine. Ескалює інциденти при storm-поведінці, визначає auto-resolve кандидатів. Без LLM, policy-driven.",
            "parameters": {
                "type": "object",
                "properties": {
                    "action": {
                        "type": "string",
                        "enum": ["evaluate", "auto_resolve_candidates"],
                        "description": "evaluate — ескалація по thresholds; auto_resolve_candidates — знайти інциденти без алертів"
                    },
                    "env": {"type": "string", "description": "Фільтр по env (prod/staging/any)"},
                    "window_minutes": {"type": "integer", "description": "Вікно для аналізу (default 60)"},
                    "limit": {"type": "integer", "description": "Ліміт записів (default 100)"},
                    "no_alerts_minutes": {"type": "integer", "description": "Хвилин без алертів для auto_resolve_candidates"},
                    "dry_run": {"type": "boolean", "description": "dry_run=true — не записує зміни, лише повертає candidates"}
                },
                "required": ["action"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "risk_engine_tool",
            "description": "📊 Service Risk Index Engine (deterministic, no LLM). Числовий ризик-скор сервісу: open incidents, recurrence, followups, SLO, escalations. Тепер включає trend (Δ24h, Δ7d, slope, volatility).",
            "parameters": {
                "type": "object",
                "properties": {
                    "action": {
                        "type": "string",
                        "enum": ["service", "dashboard", "policy"],
                        "description": "service — risk score для одного сервісу; dashboard — топ ризикових сервісів; policy — ефективна policy"
                    },
                    "service": {"type": "string", "description": "Назва сервісу (для service action)"},
                    "env": {"type": "string", "description": "Середовище: prod|staging"},
                    "top_n": {"type": "integer", "description": "Топ N сервісів (для dashboard)"},
                    "window_hours": {"type": "integer", "description": "Вікно аналізу в годинах (default 24)"},
                    "include_trend": {"type": "boolean", "description": "Чи включати trend (Δ24h, Δ7d) з history store. Default true."}
                },
                "required": ["action"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "risk_history_tool",
            "description": "📈 Risk History Tool — snapshot, cleanup, trend series для risk score. Зберігає hourly snapshots у Postgres. RBAC: tools.risk.write.",
            "parameters": {
                "type": "object",
                "properties": {
                    "action": {
                        "type": "string",
                        "enum": ["snapshot", "cleanup", "series", "digest"],
                        "description": "snapshot — зробити snapshot всіх сервісів; cleanup — видалити старі записи; series — отримати series для сервісу; digest — згенерувати daily digest"
                    },
                    "env": {"type": "string", "description": "Середовище: prod|staging"},
                    "service": {"type": "string", "description": "Назва сервісу (для series action)"},
                    "hours": {"type": "integer", "description": "Вікно в годинах для series (default 168 = 7d)"},
                    "retention_days": {"type": "integer", "description": "Retention для cleanup (default з policy)"}
                },
                "required": ["action"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "backlog_tool",
            "description": "📋 Engineering Backlog Tool — CRUD + workflow transitions + auto-generation from Risk/Pressure digests. Source-of-truth for platform engineering priorities. RBAC: tools.backlog.read/write/admin.",
            "parameters": {
                "type": "object",
                "properties": {
                    "action": {
                        "type": "string",
                        "enum": ["list", "get", "dashboard", "create", "upsert", "set_status",
                                 "add_comment", "close", "auto_generate_weekly", "cleanup"],
                        "description": "list/get/dashboard=read; create/upsert/set_status/add_comment/close=write; auto_generate_weekly/cleanup=admin"
                    },
                    "env": {"type": "string", "description": "Environment: prod|staging|dev"},
                    "id": {"type": "string", "description": "Backlog item ID (for get/set_status/add_comment/close)"},
                    "service": {"type": "string", "description": "Filter by service name"},
                    "status": {"type": "string", "description": "Filter/set status"},
                    "owner": {"type": "string", "description": "Filter/set owner"},
                    "category": {"type": "string", "description": "Filter by category"},
                    "limit": {"type": "integer", "description": "Max results (list)"},
                    "offset": {"type": "integer", "description": "Pagination offset (list)"},
                    "due_before": {"type": "string", "description": "Filter due_date < YYYY-MM-DD"},
                    "item": {"type": "object", "description": "BacklogItem fields for create/upsert"},
                    "message": {"type": "string", "description": "Comment message or status note"},
                    "actor": {"type": "string", "description": "Who is making the change"},
                    "week_str": {"type": "string", "description": "ISO week override for auto_generate_weekly"},
                    "retention_days": {"type": "integer", "description": "Days to retain closed items (cleanup)"}
                },
                "required": ["action"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "architecture_pressure_tool",
            "description": "🏗️ Architecture Pressure Tool — deterministic structural health index per service. Measures 30d accumulated strain (recurrences, regressions, escalations, followup debt). RBAC: tools.pressure.read/write.",
            "parameters": {
                "type": "object",
                "properties": {
                    "action": {
                        "type": "string",
                        "enum": ["service", "dashboard", "digest"],
                        "description": "service — pressure report for one service; dashboard — top-N pressure overview; digest — generate weekly platform priority digest"
                    },
                    "service": {"type": "string", "description": "Service name (for service action)"},
                    "env": {"type": "string", "description": "Environment: prod|staging|dev"},
                    "top_n": {"type": "integer", "description": "Top-N services for dashboard/digest"},
                    "lookback_days": {"type": "integer", "description": "Lookback window in days (default 30)"},
                    "auto_followup": {"type": "boolean", "description": "Auto-create architecture-review followups (digest action)"}
                },
                "required": ["action"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "incident_intelligence_tool",
            "description": "🧠 Incident Intelligence Layer (deterministic, no LLM). Кореляція, рекурентність, root-cause buckets, weekly digest — без LLM.",
            "parameters": {
                "type": "object",
                "properties": {
                    "action": {
                        "type": "string",
                        "enum": ["correlate", "recurrence", "buckets", "weekly_digest"],
                        "description": "correlate — related incidents; recurrence — frequency window; buckets — root-cause clusters; weekly_digest — full report"
                    },
                    "incident_id": {"type": "string", "description": "ID інциденту для correlate"},
                    "window_days": {"type": "integer", "description": "Вікно аналізу в днях"},
                    "service": {"type": "string", "description": "Фільтр по сервісу (для recurrence/buckets)"},
                    "append_note": {"type": "boolean", "description": "Додати note в timeline інциденту (для correlate)"},
                    "save_artifacts": {"type": "boolean", "description": "Зберегти md+json у ops/reports/incidents (для weekly_digest)"}
                },
                "required": ["action"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "repo_tool",
            "description": "📂 Read-only доступ до файловї системи репозиторію. Перегляд коду, конфігів, структури проєкту. НЕ записує і не виконує код.",
            "parameters": {
                "type": "object",
                "properties": {
                    "action": {
                        "type": "string",
                        "enum": ["tree", "read", "search", "metadata"],
                        "description": "Дія: tree (структура папок), read (читання файлу), search (пошук тексту), metadata (git info)"
                    },
                    "path": {
                        "type": "string",
                        "description": "Шлях до файлу або папки відносно repo root"
                    },
                    "start_line": {
                        "type": "integer",
                        "description": "Початковий рядок для читання (для read)"
                    },
                    "end_line": {
                        "type": "integer",
                        "description": "Кінцевий рядок для читання (для read)"
                    },
                    "depth": {
                        "type": "integer",
                        "description": "Глибина дерева для tree (default: 3)",
                        "default": 3
                    },
                    "glob": {
                        "type": "string",
                        "description": "Glob паттерн для фільтрації (наприклад '**/*.py')"
                    },
                    "query": {
                        "type": "string",
                        "description": "Текст для пошуку (для search)"
                    },
                    "limit": {
                        "type": "integer",
                        "description": "Максимальна кількість результатів",
                        "default": 50
                    },
                    "max_bytes": {
                        "type": "integer",
                        "description": "Максимальний розмір файлу для читання (default: 200KB)",
                        "default": 204800
                    }
                },
                "required": ["action"]
            }
        }
    },
    # PRIORITY 10: PR Reviewer Tool
    {
        "type": "function",
        "function": {
            "name": "pr_reviewer_tool",
            "description": "🔍 Рев'ю коду з PR/diff. Аналізує зміни, знаходить security issues, blocking problems, ризики регресій. Підтримує blocking_only та full_review режими.",
            "parameters": {
                "type": "object",
                "properties": {
                    "mode": {
                        "type": "string",
                        "enum": ["blocking_only", "full_review"],
                        "description": "Режим рев'ю: blocking_only - тільки critical/high, full_review - повний аналіз",
                        "default": "full_review"
                    },
                    "context": {
                        "type": "object",
                        "properties": {
                            "repo": {
                                "type": "object",
                                "properties": {
                                    "name": {"type": "string"},
                                    "commit_base": {"type": "string"},
                                    "commit_head": {"type": "string"}
                                }
                            },
                            "change_summary": {"type": "string"},
                            "risk_profile": {
                                "type": "string",
                                "enum": ["default", "security_strict", "release_gate"],
                                "default": "default"
                            }
                        }
                    },
                    "diff": {
                        "type": "object",
                        "properties": {
                            "format": {
                                "type": "string",
                                "enum": ["unified"],
                                "default": "unified"
                            },
                            "text": {
                                "type": "string",
                                "description": "Unified diff текст"
                            },
                            "max_files": {
                                "type": "integer",
                                "default": 200
                            },
                            "max_chars": {
                                "type": "integer",
                                "default": 400000
                            }
                        },
                        "required": ["text"]
                    },
                    "options": {
                        "type": "object",
                        "properties": {
                            "include_tests_checklist": {"type": "boolean", "default": True},
                            "include_deploy_risks": {"type": "boolean", "default": True},
                            "include_migration_risks": {"type": "boolean", "default": True},
                            "language_hint": {
                                "type": "string",
                                "enum": ["python", "node", "mixed", "unknown"],
                                "default": "unknown"
                            }
                        }
                    }
                },
                "required": ["diff"]
            }
        }
    },
    # PRIORITY 11: Contract Tool (OpenAPI/JSON Schema)
    {
        "type": "function",
        "function": {
            "name": "contract_tool",
            "description": "📜 Перевірка OpenAPI контрактів. Lint, diff, breaking changes detection. Для QA та release gate.",
            "parameters": {
                "type": "object",
                "properties": {
                    "action": {
                        "type": "string",
                        "enum": ["lint_openapi", "diff_openapi", "generate_client_stub"],
                        "description": "Дія: lint - перевірка якості, diff - порівняння версій, stub - генерація клієнта"
                    },
                    "inputs": {
                        "type": "object",
                        "properties": {
                            "format": {
                                "type": "string",
                                "enum": ["openapi_json", "openapi_yaml"],
                                "default": "openapi_yaml"
                            },
                            "base": {
                                "type": "object",
                                "properties": {
                                    "source": {"type": "string", "enum": ["text", "repo_path"]},
                                    "value": {"type": "string"}
                                }
                            },
                            "head": {
                                "type": "object",
                                "properties": {
                                    "source": {"type": "string", "enum": ["text", "repo_path"]},
                                    "value": {"type": "string"}
                                }
                            }
                        }
                    },
                    "options": {
                        "type": "object",
                        "properties": {
                            "fail_on_breaking": {"type": "boolean", "default": False},
                            "strict": {"type": "boolean", "default": False},
                            "max_chars": {"type": "integer", "default": 800000},
                            "service_name": {"type": "string"},
                            "visibility": {"type": "string", "enum": ["internal", "public"], "default": "internal"}
                        }
                    }
                },
                "required": ["action", "inputs"]
            }
        }
    },
    # PRIORITY 12: Oncall/Runbook Tool
    {
        "type": "function",
        "function": {
            "name": "oncall_tool",
            "description": "📋 Операційна інформація: сервіси, health, деплої, runbooks + повний incident CRUD (create/get/list/close/append_event/attach_artifact). Read-only для більшості, write для incident_* actions.",
            "parameters": {
                "type": "object",
                "properties": {
                    "action": {
                        "type": "string",
                        "enum": [
                            "services_list",
                            "service_status",
                            "service_health",
                            "deployments_recent",
                            "runbook_search",
                            "runbook_read",
                            "incident_create",
                            "incident_get",
                            "incident_list",
                            "incident_close",
                            "incident_append_event",
                            "incident_attach_artifact",
                            "incident_followups_summary",
                            "alert_to_incident",
                            "signature_should_triage",
                            "signature_mark_triage",
                            "signature_mark_alert"
                        ],
                        "description": "Дія oncall tool"
                    },
                    "params": {
                        "type": "object",
                        "properties": {
                            "service_name": {"type": "string"},
                            "health_endpoint": {"type": "string"},
                            "since": {"type": "string"},
                            "severity": {"type": "string"},
                            "limit": {"type": "integer", "default": 20},
                            "query": {"type": "string"},
                            "runbook_path": {"type": "string"},
                            "workspace_id": {"type": "string"},
                            "incident_id": {"type": "string"},
                            "env": {"type": "string", "enum": ["prod", "staging", "dev"]},
                            "title": {"type": "string"},
                            "summary": {"type": "string"},
                            "started_at": {"type": "string"},
                            "ended_at": {"type": "string"},
                            "resolution_summary": {"type": "string"},
                            "status": {"type": "string"},
                            "service": {"type": "string"},
                            "type": {"type": "string", "enum": ["note", "decision", "action", "status_change", "followup", "metric", "log"]},
                            "message": {"type": "string"},
                            "meta": {"type": "object"},
                            "kind": {"type": "string", "enum": ["triage_report", "postmortem_draft", "postmortem_final", "attachment"]},
                            "format": {"type": "string", "enum": ["json", "md", "txt"]},
                            "content_base64": {"type": "string"},
                            "filename": {"type": "string"}
                        }
                    }
                },
                "required": ["action"]
            }
        }
    },
    # PRIORITY 13: Observability Tool (metrics/logs/traces)
    {
        "type": "function",
        "function": {
            "name": "observability_tool",
            "description": "📊 Метрики, логи, трейси. Prometheus, Loki, Tempo інтеграція. Read-only доступ до observability даних.",
            "parameters": {
                "type": "object",
                "properties": {
                    "action": {
                        "type": "string",
                        "enum": [
                            "metrics_query",
                            "metrics_range",
                            "logs_query",
                            "traces_query",
                            "service_overview",
                            "slo_snapshot"
                        ],
                        "description": "Дія: метрики, логи, трейси, SLO"
                    },
                    "params": {
                        "type": "object",
                        "properties": {
                            "service": {"type": "string"},
                            "query": {"type": "string"},
                            "time_range": {
                                "type": "object",
                                "properties": {
                                    "from": {"type": "string"},
                                    "to": {"type": "string"}
                                }
                            },
                            "step_seconds": {"type": "integer", "default": 30},
                            "limit": {"type": "integer", "default": 200},
                            "datasource": {
                                "type": "string",
                                "enum": ["prometheus", "loki", "tempo"],
                                "default": "prometheus"
                            },
                            "filters": {"type": "object"},
                            "trace_id": {"type": "string"}
                        }
                    }
                },
                "required": ["action"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "config_linter_tool",
            "description": "🔒 Перевірка конфігурацій на secrets, небезпечні налаштування, policy violations. Детермінований linter без LLM.",
            "parameters": {
                "type": "object",
                "properties": {
                    "source": {
                        "type": "object",
                        "properties": {
                            "type": {
                                "type": "string",
                                "enum": ["diff_text", "paths"],
                                "description": "Джерело для перевірки: diff (unified format) або шляхи до файлів"
                            },
                            "diff_text": {"type": "string", "description": "Unified diff текст (якщо type=diff_text)"},
                            "paths": {
                                "type": "array",
                                "items": {"type": "string"},
                                "description": "Список шляхів до файлів (якщо type=paths)"
                            }
                        },
                        "required": ["type"]
                    },
                    "options": {
                        "type": "object",
                        "properties": {
                            "strict": {"type": "boolean", "default": False, "description": "Strict mode - fail на medium"},
                            "max_chars": {"type": "integer", "default": 400000},
                            "max_files": {"type": "integer", "default": 200},
                            "mask_evidence": {"type": "boolean", "default": True},
                            "include_recommendations": {"type": "boolean", "default": True}
                        }
                    }
                },
                "required": ["source"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "threatmodel_tool",
            "description": "🛡️ Threat Model + Security Checklist. STRIDE-based аналіз сервісів, diff, OpenAPI. Генерує assets, entrypoints, threats, controls, security checklist.",
            "parameters": {
                "type": "object",
                "properties": {
                    "action": {
                        "type": "string",
                        "enum": ["analyze_service", "analyze_diff", "generate_checklist"],
                        "description": "Дія: аналіз сервісу, дифу, або генерація чеклісту"
                    },
                    "inputs": {
                        "type": "object",
                        "properties": {
                            "service_name": {"type": "string", "description": "Назва сервісу"},
                            "artifacts": {
                                "type": "array",
                                "items": {
                                    "type": "object",
                                    "properties": {
                                        "type": {"type": "string", "enum": ["openapi", "diff", "text", "dataflows"]},
                                        "source": {"type": "string", "enum": ["text", "repo_path"]},
                                        "value": {"type": "string"}
                                    },
                                    "required": ["type", "source", "value"]
                                }
                            }
                        }
                    },
                    "options": {
                        "type": "object",
                        "properties": {
                            "max_chars": {"type": "integer", "default": 600000},
                            "strict": {"type": "boolean", "default": False},
                            "include_llm_enrichment": {"type": "boolean", "default": False},
                            "risk_profile": {
                                "type": "string",
                                "enum": ["default", "agentic_tools", "public_api"],
                                "default": "default"
                            }
                        }
                    }
                },
                "required": ["action", "inputs"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "job_orchestrator_tool",
            "description": "🚀 Job Orchestrator - Запуск контрольованих операційних задач (deploy/check/backup/smoke/drift). Dry-run, RBAC, аудит.",
            "parameters": {
                "type": "object",
                "properties": {
                    "action": {
                        "type": "string",
                        "enum": ["list_tasks", "start_task", "get_job", "cancel_job"],
                        "description": "Дія: список задач, запуск, статус, скасування"
                    },
                    "params": {
                        "type": "object",
                        "properties": {
                            "filter": {
                                "type": "object",
                                "properties": {
                                    "tag": {"type": "string", "enum": ["deploy", "backup", "smoke", "migrate", "drift", "ops"]},
                                    "service": {"type": "string"}
                                }
                            },
                            "task_id": {"type": "string", "description": "ID задачі для запуску"},
                            "dry_run": {"type": "boolean", "default": False, "description": "Тільки план виконання"},
                            "inputs": {"type": "object", "description": "Параметри задачі"},
                            "idempotency_key": {"type": "string", "description": "Унікальний ключ для запобігання дублів"},
                            "job_id": {"type": "string", "description": "ID job для отримання статусу"},
                            "reason": {"type": "string", "description": "Причина скасування"}
                        }
                    }
                },
                "required": ["action"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "kb_tool",
            "description": "📚 Knowledge Base - Пошук ADR, документації, runbooks. Швидкий доступ до організаційної правди з цитуванням.",
            "parameters": {
                "type": "object",
                "properties": {
                    "action": {
                        "type": "string",
                        "enum": ["search", "open", "snippets", "sources"],
                        "description": "Дія: пошук, відкрити файл, сниппети, список джерел"
                    },
                    "params": {
                        "type": "object",
                        "properties": {
                            "query": {"type": "string", "description": "Пошуковий запит"},
                            "paths": {
                                "type": "array",
                                "items": {"type": "string"},
                                "description": "Директорії для пошуку"
                            },
                            "file_glob": {"type": "string", "description": "Glob паттерн файлів"},
                            "limit": {"type": "integer", "default": 20, "description": "Ліміт результатів"},
                            "start_line": {"type": "integer", "description": "Початкова лінія"},
                            "end_line": {"type": "integer", "description": "Кінцева лінія"},
                            "max_bytes": {"type": "integer", "default": 200000},
                            "context_lines": {"type": "integer", "default": 4, "description": "Лінії контексту"},
                            "max_chars_per_snippet": {"type": "integer", "default": 800},
                            "path": {"type": "string", "description": "Шлях до файлу"}
                        }
                    }
                },
                "required": ["action"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "notion_tool",
            "description": "🗂️ Notion integration: create/update databases, pages, tasks; check API connectivity.",
            "parameters": {
                "type": "object",
                "properties": {
                    "action": {
                        "type": "string",
                        "enum": ["status", "create_database", "create_page", "update_page", "create_task"],
                        "description": "Notion action"
                    },
                    "database_id": {"type": "string", "description": "Target database ID"},
                    "page_id": {"type": "string", "description": "Target page ID"},
                    "parent_page_id": {"type": "string", "description": "Parent page ID for database"},
                    "title": {"type": "string", "description": "Title for page/task/database"},
                    "properties": {"type": "object", "description": "Raw Notion properties object"},
                    "content": {"type": "string", "description": "Optional page body text"},
                    "status": {"type": "string", "description": "Task status"},
                    "due_date": {"type": "string", "description": "Task due date (YYYY-MM-DD)"}
                },
                "required": ["action"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "pieces_tool",
            "description": "🧩 Pieces OS integration: status/ping for local workstream processors.",
            "parameters": {
                "type": "object",
                "properties": {
                    "action": {
                        "type": "string",
                        "enum": ["status", "workstream_status", "ping"],
                        "description": "Pieces action (defaults to status)"
                    },
                    "base_url": {
                        "type": "string",
                        "description": "Optional override for Pieces OS base URL"
                    }
                }
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "calendar_tool",
            "description": "📅 Calendar management via calendar-service (CalDAV/Radicale).",
            "parameters": {
                "type": "object",
                "properties": {
                    "action": {
                        "type": "string",
                        "enum": ["connect", "list_calendars", "list_events", "get_event", "create_event", "update_event", "delete_event", "set_reminder"],
                        "description": "Calendar action"
                    },
                    "workspace_id": {"type": "string", "description": "Workspace id"},
                    "user_id": {"type": "string", "description": "User id"},
                    "account_id": {"type": "string", "description": "Connected calendar account id"},
                    "calendar_id": {"type": "string", "description": "Optional calendar id"},
                    "params": {"type": "object", "description": "Action-specific payload"}
                },
                "required": ["action"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "agent_email_tool",
            "description": "✉️ Agent email automation: inbox lifecycle, send/receive, email analysis.",
            "parameters": {
                "type": "object",
                "properties": {
                    "action": {
                        "type": "string",
                        "enum": ["create_inbox", "list_inboxes", "delete_inbox", "send", "receive", "analyze_email"],
                        "description": "Email action"
                    },
                    "to": {"type": "array", "items": {"type": "string"}},
                    "subject": {"type": "string"},
                    "body": {"type": "string"},
                    "html": {"type": "string"},
                    "attachments": {"type": "array", "items": {"type": "string"}},
                    "cc": {"type": "array", "items": {"type": "string"}},
                    "bcc": {"type": "array", "items": {"type": "string"}},
                    "inbox_id": {"type": "string"},
                    "unread_only": {"type": "boolean"},
                    "limit": {"type": "integer"},
                    "query": {"type": "string"},
                    "email": {"type": "object"}
                },
                "required": ["action"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "browser_tool",
            "description": "🌐 Browser automation with managed per-agent sessions.",
            "parameters": {
                "type": "object",
                "properties": {
                    "action": {
                        "type": "string",
                        "enum": ["start_session", "restore_session", "close_session", "goto", "act", "extract", "observe", "screenshot", "fill_form", "wait_for", "get_current_url", "get_page_text"],
                        "description": "Browser action"
                    },
                    "url": {"type": "string"},
                    "instruction": {"type": "string"},
                    "schema": {"type": "object"},
                    "fields": {"type": "array", "items": {"type": "object"}},
                    "selector_or_text": {"type": "string"},
                    "timeout": {"type": "integer"},
                    "headless": {"type": "boolean"},
                    "proxy": {"type": "string"},
                    "stealth": {"type": "boolean"},
                    "restore_existing": {"type": "boolean"}
                },
                "required": ["action"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "safe_code_executor_tool",
            "description": "🧪 Sandboxed code execution with security validation and limits.",
            "parameters": {
                "type": "object",
                "properties": {
                    "action": {
                        "type": "string",
                        "enum": ["validate", "execute", "execute_async", "get_job_result", "get_stats", "kill_process"],
                        "description": "Executor action"
                    },
                    "language": {"type": "string", "enum": ["python", "javascript", "js"]},
                    "code": {"type": "string"},
                    "stdin": {"type": "string"},
                    "limits": {"type": "object"},
                    "job_id": {"type": "string"},
                    "execution_id": {"type": "string"}
                },
                "required": ["action"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "secure_vault_tool",
            "description": "🔐 Encrypted credential storage for agents.",
            "parameters": {
                "type": "object",
                "properties": {
                    "action": {
                        "type": "string",
                        "enum": ["store", "get", "delete", "list", "check_expiring", "vacuum"],
                        "description": "Vault action"
                    },
                    "service": {"type": "string"},
                    "credential_name": {"type": "string"},
                    "value": {},
                    "ttl_seconds": {"type": "integer"},
                    "days": {"type": "integer"}
                },
                "required": ["action"]
            }
        }
    }
]


@dataclass
class ToolResult:
    """Result of tool execution"""
    success: bool
    result: Any
    error: Optional[str] = None
    image_base64: Optional[str] = None  # For image generation results
    file_base64: Optional[str] = None
    file_name: Optional[str] = None
    file_mime: Optional[str] = None


class ToolManager:
    """Manages tool execution for the agent"""
    
    def __init__(self, config: Dict[str, Any]):
        self.config = config
        self.http_client = httpx.AsyncClient(timeout=60.0)
        self.swapper_url = os.getenv("SWAPPER_URL", "http://swapper-service:8890")
        self.comfy_agent_url = os.getenv("COMFY_AGENT_URL", "http://212.8.58.133:8880")
        self.image_gen_service_url = os.getenv("IMAGE_GEN_SERVICE_URL", "http://image-gen-service:7860")
        self.calendar_service_url = os.getenv("CALENDAR_SERVICE_URL", "http://calendar-service:8001").rstrip("/")
        self.oneok_crm_url = os.getenv("ONEOK_CRM_BASE_URL", "http://oneok-crm-adapter:8088").rstrip("/")
        self.oneok_calc_url = os.getenv("ONEOK_CALC_BASE_URL", "http://oneok-calc-adapter:8089").rstrip("/")
        self.oneok_docs_url = os.getenv("ONEOK_DOCS_BASE_URL", "http://oneok-docs-adapter:8090").rstrip("/")
        self.oneok_schedule_url = os.getenv("ONEOK_SCHEDULE_BASE_URL", "http://oneok-schedule-adapter:8091").rstrip("/")
        self.oneok_adapter_api_key = os.getenv("ONEOK_ADAPTER_API_KEY", "").strip()
        self._agent_email_clients: Dict[str, Any] = {}
        self._browser_clients: Dict[str, Any] = {}
        self._safe_code_executor: Any = None
        self._secure_vault: Any = None
        self.tools_config = self._load_tools_config()
        
    def _load_tools_config(self) -> Dict[str, Dict]:
        """Load tool endpoints from config"""
        tools = {}
        agent_config = self.config.get("agents", {}).get("helion", {})
        for tool in agent_config.get("tools", []):
            if "endpoint" in tool:
                tools[tool["id"]] = {
                    "endpoint": tool["endpoint"],
                    "method": tool.get("method", "POST")
                }
        return tools
    
    def get_tool_definitions(self, agent_id: str = None) -> List[Dict]:
        """Get tool definitions for function calling, filtered by agent permissions"""
        if not agent_id:
            return TOOL_DEFINITIONS
        
        # Get allowed tools for this agent
        allowed_tools = get_agent_tools(agent_id)
        
        # Filter tool definitions
        filtered = []
        for tool_def in TOOL_DEFINITIONS:
            tool_name = tool_def.get("function", {}).get("name")
            if tool_name in allowed_tools:
                filtered.append(tool_def)
        
        tool_names = [t.get("function", {}).get("name") for t in filtered]
        logger.debug(f"Agent {agent_id} has {len(filtered)} tools: {tool_names}")
        return filtered
    
    async def execute_tool(
        self,
        tool_name: str,
        arguments: Dict[str, Any],
        agent_id: str = None,
        chat_id: str = None,
        user_id: str = None,
        workspace_id: str = "default",
    ) -> ToolResult:
        """Execute a tool and return result. Applies governance middleware (RBAC, limits, audit)."""
        logger.info(f"🔧 Executing tool: {tool_name} for agent={agent_id}")

        # Check agent permission if agent_id provided
        if agent_id and not is_tool_allowed(agent_id, tool_name):
            logger.warning(f"⚠️ Tool {tool_name} not allowed for agent {agent_id}")
            return ToolResult(success=False, result=None, error=f"Tool {tool_name} not available for this agent")

        # ── Tool Governance: pre-call (RBAC + limits) ──────────────────────
        try:
            from tool_governance import get_governance
            governance = get_governance()
            # Determine action from arguments (best-effort)
            _action = (arguments or {}).get("action", "_default")
            _input_text = str(arguments) if arguments else ""
            pre = governance.pre_call(
                tool=tool_name,
                action=_action,
                agent_id=agent_id or "anonymous",
                user_id=user_id or "unknown",
                workspace_id=workspace_id,
                input_text=_input_text,
            )
            if not pre.allowed:
                return ToolResult(success=False, result=None, error=pre.reason)
            _gov_ctx = pre.call_ctx
        except ImportError:
            logger.debug("tool_governance not available, skipping governance checks")
            governance = None
            _gov_ctx = None

        result: ToolResult = ToolResult(success=False, result=None, error="Unknown tool")
        try:
            # Priority 1: Memory/Knowledge tools
            if tool_name == "memory_search":
                result = await self._memory_search(arguments, agent_id=agent_id, chat_id=chat_id, user_id=user_id)
            elif tool_name == "graph_query":
                result = await self._graph_query(arguments, agent_id=agent_id)
            # Priority 2: Web tools
            elif tool_name == "web_search":
                result = await self._web_search(arguments)
            elif tool_name == "web_extract":
                result = await self._web_extract(arguments)
            elif tool_name == "image_generate":
                result = await self._image_generate(arguments)
            elif tool_name == "comfy_generate_image":
                result = await self._comfy_generate_image(arguments)
            elif tool_name == "comfy_generate_video":
                result = await self._comfy_generate_video(arguments)
            elif tool_name == "remember_fact":
                result = await self._remember_fact(arguments, agent_id=agent_id, chat_id=chat_id, user_id=user_id)
            # Priority 4: Presentation tools
            elif tool_name == "presentation_create":
                result = await self._presentation_create(arguments)
            elif tool_name == "presentation_status":
                result = await self._presentation_status(arguments)
            elif tool_name == "presentation_download":
                result = await self._presentation_download(arguments)
            # Priority 5: Web scraping tools
            elif tool_name == "crawl4ai_scrape":
                result = await self._crawl4ai_scrape(arguments)
            # Priority 6: TTS tools
            elif tool_name == "tts_speak":
                result = await self._tts_speak(arguments)
            # Priority 6: File artifacts
            elif tool_name == "file_tool":
                result = await self._file_tool(arguments)
            # Priority 7: Market Data (SenpAI)
            elif tool_name == "market_data":
                result = await self._market_data(arguments)
            elif tool_name == "binance_bots_top":
                result = await self._binance_bots_top(arguments)
            elif tool_name == "binance_account_bots":
                result = await self._binance_account_bots(arguments)
            # Priority 8: 1OK tools
            elif tool_name == "crm_search_client":
                result = await self._crm_search_client(arguments)
            elif tool_name == "crm_upsert_client":
                result = await self._crm_upsert_client(arguments)
            elif tool_name == "crm_upsert_site":
                result = await self._crm_upsert_site(arguments)
            elif tool_name == "crm_upsert_window_unit":
                result = await self._crm_upsert_window_unit(arguments)
            elif tool_name == "crm_create_quote":
                result = await self._crm_create_quote(arguments)
            elif tool_name == "crm_update_quote":
                result = await self._crm_update_quote(arguments)
            elif tool_name == "crm_create_job":
                result = await self._crm_create_job(arguments)
            elif tool_name == "calc_window_quote":
                result = await self._calc_window_quote(arguments)
            elif tool_name == "docs_render_quote_pdf":
                result = await self._docs_render_quote_pdf(arguments)
            elif tool_name == "docs_render_invoice_pdf":
                result = await self._docs_render_invoice_pdf(arguments)
            elif tool_name == "schedule_propose_slots":
                result = await self._schedule_propose_slots(arguments)
            elif tool_name == "schedule_confirm_slot":
                result = await self._schedule_confirm_slot(arguments)
            # Priority 9+: Data Governance Tool
            elif tool_name == "data_governance_tool":
                result = await self._data_governance_tool(arguments)
            # Priority 9+: Cost Analyzer Tool
            elif tool_name == "cost_analyzer_tool":
                result = await self._cost_analyzer_tool(arguments)
            # Priority 9+: Dependency Scanner Tool
            elif tool_name == "dependency_scanner_tool":
                result = await self._dependency_scanner_tool(arguments)
            # Priority 9+: Drift Analyzer Tool
            elif tool_name == "drift_analyzer_tool":
                result = await self._drift_analyzer_tool(arguments)
            # Alert Ingest Tool
            elif tool_name == "alert_ingest_tool":
                result = await self._alert_ingest_tool(arguments)
            elif tool_name == "incident_escalation_tool":
                result = await self._incident_escalation_tool(arguments)
            elif tool_name == "incident_intelligence_tool":
                result = await self._incident_intelligence_tool(arguments)
            elif tool_name == "risk_engine_tool":
                result = await self._risk_engine_tool(arguments, agent_id=agent_id)
            elif tool_name == "risk_history_tool":
                result = await self._risk_history_tool(arguments, agent_id=agent_id)
            elif tool_name == "backlog_tool":
                result = await self._backlog_tool(arguments, agent_id=agent_id)
            elif tool_name == "architecture_pressure_tool":
                result = await self._architecture_pressure_tool(arguments, agent_id=agent_id)
            # Priority 9: Repo Tool
            elif tool_name == "repo_tool":
                result = await self._repo_tool(arguments)
            # Priority 10: PR Reviewer Tool
            elif tool_name == "pr_reviewer_tool":
                result = await self._pr_reviewer_tool(arguments)
            # Priority 11: Contract Tool (OpenAPI)
            elif tool_name == "contract_tool":
                result = await self._contract_tool(arguments)
            # Priority 12: Oncall/Runbook Tool
            elif tool_name == "oncall_tool":
                result = await self._oncall_tool(arguments, agent_id=agent_id)
            # Priority 13: Observability Tool
            elif tool_name == "observability_tool":
                result = await self._observability_tool(arguments)
            # Priority 14: Config Linter Tool
            elif tool_name == "config_linter_tool":
                result = await self._config_linter_tool(arguments)
            # Priority 15: ThreatModel Tool
            elif tool_name == "threatmodel_tool":
                result = await self._threatmodel_tool(arguments)
            # Priority 16: Job Orchestrator Tool
            elif tool_name == "job_orchestrator_tool":
                result = await self._job_orchestrator_tool(arguments)
            # Priority 17: Knowledge Base Tool
            elif tool_name == "kb_tool":
                result = await self._kb_tool(arguments)
            # Priority 18: Notion integration
            elif tool_name == "notion_tool":
                result = await self._notion_tool(arguments)
            # Priority 18: Pieces OS integration
            elif tool_name == "pieces_tool":
                result = await self._pieces_tool(arguments)
            # Priority 19: Calendar integration
            elif tool_name == "calendar_tool":
                enriched = dict(arguments or {})
                enriched.setdefault("agent_id", agent_id or "sofiia")
                enriched.setdefault("workspace_id", workspace_id or "default")
                if user_id:
                    enriched.setdefault("user_id", user_id)
                result = await self._calendar_tool(enriched)
            # Priority 20: Local automations toolkit
            elif tool_name == "agent_email_tool":
                enriched = dict(arguments or {})
                enriched.setdefault("agent_id", agent_id or "sofiia")
                result = await self._agent_email_tool(enriched)
            elif tool_name == "browser_tool":
                enriched = dict(arguments or {})
                enriched.setdefault("agent_id", agent_id or "sofiia")
                result = await self._browser_tool(enriched)
            elif tool_name == "safe_code_executor_tool":
                enriched = dict(arguments or {})
                enriched.setdefault("agent_id", agent_id or "sofiia")
                result = await self._safe_code_executor_tool(enriched)
            elif tool_name == "secure_vault_tool":
                enriched = dict(arguments or {})
                enriched.setdefault("agent_id", agent_id or "sofiia")
                result = await self._secure_vault_tool(enriched)
            else:
                result = ToolResult(success=False, result=None, error=f"Unknown tool: {tool_name}")
        except Exception as e:
            logger.error(f"Tool execution failed: {e}")
            result = ToolResult(success=False, result=None, error=str(e))

        # ── Tool Governance: post-call audit ──────────────────────────────
        if governance and _gov_ctx:
            try:
                governance.post_call(
                    _gov_ctx,
                    result_value=result.result if result else None,
                    error=result.error if result and not result.success else None,
                )
            except Exception as _ae:
                logger.debug(f"Audit emit failed (non-fatal): {_ae}")

        return result

    @staticmethod
    def _sanitize_file_name(name: Optional[str], default_name: str, force_ext: Optional[str] = None) -> str:
        raw = (name or default_name).strip() or default_name
        base = PurePath(raw).name
        if not base:
            base = default_name
        if force_ext and not base.lower().endswith(force_ext):
            base = f"{base}{force_ext}"
        return base

    @staticmethod
    def _b64_from_bytes(data: bytes) -> str:
        return base64.b64encode(data).decode("utf-8")

    @staticmethod
    def _bytes_from_b64(value: str) -> bytes:
        return base64.b64decode(value)

    @staticmethod
    def _normalize_rows(rows: Any, headers: Optional[List[str]] = None) -> List[List[Any]]:
        if not isinstance(rows, list):
            return []
        out: List[List[Any]] = []
        for row in rows:
            if isinstance(row, list):
                out.append(row)
            elif isinstance(row, dict):
                keys = headers or list(row.keys())
                out.append([row.get(k, "") for k in keys])
            else:
                out.append([row])
        return out

    @staticmethod
    def _append_sheet_data(ws: Any, headers: List[str], rows: List[List[Any]]) -> None:
        if headers:
            ws.append(headers)
        for row in rows:
            ws.append(row)

    async def _file_tool(self, args: Dict[str, Any]) -> ToolResult:
        action = str((args or {}).get("action") or "").strip().lower()
        if not action:
            return ToolResult(success=False, result=None, error="Missing action")

        if action == "excel_create":
            return self._file_excel_create(args)
        if action == "excel_update":
            return self._file_excel_update(args)
        if action == "csv_create":
            return self._file_csv_create(args)
        if action == "csv_update":
            return self._file_csv_update(args)
        if action == "ods_create":
            return self._file_ods_create(args)
        if action == "ods_update":
            return self._file_ods_update(args)
        if action == "parquet_create":
            return self._file_parquet_create(args)
        if action == "parquet_update":
            return self._file_parquet_update(args)
        if action == "text_create":
            return self._file_text_create(args)
        if action == "text_update":
            return self._file_text_update(args)
        if action == "markdown_create":
            return self._file_markdown_create(args)
        if action == "markdown_update":
            return self._file_markdown_update(args)
        if action == "xml_export":
            return self._file_xml_export(args)
        if action == "html_export":
            return self._file_html_export(args)
        if action == "image_create":
            return self._file_image_create(args)
        if action == "image_edit":
            return self._file_image_edit(args)
        if action == "image_convert":
            return self._file_image_convert(args)
        if action == "image_bundle":
            return self._file_image_bundle(args)
        if action == "svg_export":
            return self._file_svg_export(args)
        if action == "svg_to_png":
            return self._file_svg_to_png(args)
        if action == "json_export":
            return self._file_json_export(args)
        if action == "yaml_export":
            return self._file_yaml_export(args)
        if action == "zip_bundle":
            return self._file_zip_bundle(args)
        if action == "docx_create":
            return self._file_docx_create(args)
        if action == "docx_update":
            return self._file_docx_update(args)
        if action == "pptx_create":
            return self._file_pptx_create(args)
        if action == "pptx_update":
            return self._file_pptx_update(args)
        if action == "pdf_merge":
            return self._file_pdf_merge(args)
        if action == "pdf_split":
            return self._file_pdf_split(args)
        if action == "pdf_update":
            return self._file_pdf_update(args)
        if action == "pdf_fill":
            return self._file_pdf_fill(args)
        if action == "djvu_to_pdf":
            return self._file_djvu_to_pdf(args)
        if action == "djvu_extract_text":
            return self._file_djvu_extract_text(args)

        return ToolResult(success=False, result=None, error=f"Action not implemented yet: {action}")

    async def _job_orchestrator_tool(self, args: Dict[str, Any]) -> ToolResult:
        """
        Minimal stable implementation for ops orchestration.
        Supports list_tasks and start_task(release_check).
        """
        payload = args or {}
        action = str(payload.get("action") or "list_tasks").strip().lower()
        params = payload.get("params") if isinstance(payload.get("params"), dict) else {}

        if action == "list_tasks":
            return ToolResult(
                success=True,
                result={
                    "tasks": [
                        {"task_id": "release_check", "description": "Run release governance gates"},
                    ]
                },
            )

        if action == "start_task":
            task_id = payload.get("task_id") or params.get("task_id")
            inputs = payload.get("inputs") or params.get("inputs") or {}
            agent_id = str(payload.get("agent_id") or "sofiia")

            if task_id != "release_check":
                return ToolResult(
                    success=False,
                    result=None,
                    error=f"Unsupported task_id: {task_id}",
                )
            try:
                from release_check_runner import run_release_check
                result = await run_release_check(self, inputs if isinstance(inputs, dict) else {}, agent_id)
                return ToolResult(success=True, result=result)
            except Exception as e:
                logger.exception("job_orchestrator_tool start_task failed")
                return ToolResult(success=False, result=None, error=f"release_check failed: {str(e)}")

        return ToolResult(success=False, result=None, error=f"Unsupported action: {action}")

    async def _threatmodel_tool(self, args: Dict[str, Any]) -> ToolResult:
        """
        Lightweight threat-model gate implementation for release checks.
        """
        payload = args or {}
        action = str(payload.get("action") or "analyze_diff").strip().lower()
        diff_text = str(payload.get("diff_text") or "")
        service_name = str(payload.get("service_name") or "unknown")
        risk_profile = str(payload.get("risk_profile") or "default")

        if action not in {"analyze_diff", "analyze_service"}:
            return ToolResult(success=False, result=None, error=f"Unsupported action: {action}")

        unmitigated_high = 0
        findings = []
        # Conservative heuristic: detect obvious high-risk markers in changed text.
        high_markers = ("skip auth", "auth bypass", "debug=true", "tls: false", "privileged: true")
        low = diff_text.lower()
        for marker in high_markers:
            if marker in low:
                unmitigated_high += 1
                findings.append({"severity": "high", "marker": marker})

        summary = (
            f"Threat model for {service_name}: {unmitigated_high} high-risk marker(s)"
            if diff_text
            else f"Threat model for {service_name}: no diff provided (baseline pass)"
        )
        return ToolResult(
            success=True,
            result={
                "summary": summary,
                "risk_profile": risk_profile,
                "unmitigated_high_count": unmitigated_high,
                "findings": findings,
                "recommendations": (
                    ["Review auth/TLS/debug settings in the changed files"]
                    if unmitigated_high > 0 else []
                ),
            },
        )

    async def _config_linter_tool(self, args: Dict[str, Any]) -> ToolResult:
        """
        Lightweight config lint gate for release checks.
        """
        payload = args or {}
        diff_text = str(payload.get("diff_text") or "")

        # If no diff provided, gate should not block release.
        if not diff_text.strip():
            return ToolResult(
                success=True,
                result={
                    "summary": "No diff provided; config lint skipped",
                    "blocking_count": 0,
                    "total_findings": 0,
                    "blocking": [],
                    "findings": [],
                },
            )

        blocking = []
        low = diff_text.lower()
        if "debug=true" in low or "auth_bypass" in low or "tls: false" in low:
            blocking.append({"severity": "high", "rule": "unsafe_config_change"})

        return ToolResult(
            success=True,
            result={
                "summary": f"Config lint found {len(blocking)} blocking issue(s)",
                "blocking_count": len(blocking),
                "total_findings": len(blocking),
                "blocking": blocking,
                "findings": [],
            },
        )

    async def _observability_tool(self, args: Dict[str, Any]) -> ToolResult:
        """
        Lightweight observability tool for slo_snapshot gate.
        """
        payload = args or {}
        action = str(payload.get("action") or "").strip().lower()
        if action != "slo_snapshot":
            return ToolResult(success=False, result=None, error=f"Unsupported action: {action}")
        return ToolResult(
            success=True,
            result={
                "violations": [],
                "metrics": {},
                "thresholds": {},
                "skipped": True,
            },
        )

    async def _notion_tool(self, args: Dict[str, Any]) -> ToolResult:
        """
        Notion API integration:
        - status
        - create_database
        - create_page
        - update_page
        - create_task
        """
        payload = args or {}
        action = str(payload.get("action") or "").strip().lower()
        api_key = os.getenv("NOTION_API_KEY", os.getenv("NOTION_TOKEN", "")).strip()
        notion_ver = os.getenv("NOTION_VERSION", "2022-06-28").strip()
        if not api_key:
            return ToolResult(success=False, result=None, error="Notion API key not configured (NOTION_API_KEY)")
        if not action:
            return ToolResult(success=False, result=None, error="Missing action")

        headers = {
            "Authorization": f"Bearer {api_key}",
            "Notion-Version": notion_ver,
            "Content-Type": "application/json",
        }
        base_url = "https://api.notion.com/v1"

        try:
            if action == "status":
                resp = await self.http_client.get(f"{base_url}/users/me", headers=headers, timeout=8.0)
                ok = resp.status_code == 200
                data = resp.json() if ok else {"status_code": resp.status_code, "text": resp.text[:300]}
                return ToolResult(success=ok, result={"status": "ok" if ok else "failed", "data": data},
                                  error=None if ok else f"Notion HTTP {resp.status_code}")

            if action == "create_database":
                parent_page_id = str(payload.get("parent_page_id") or "").strip()
                title = str(payload.get("title") or "New Database").strip()
                if not parent_page_id:
                    return ToolResult(success=False, result=None, error="parent_page_id is required")
                body = {
                    "parent": {"type": "page_id", "page_id": parent_page_id},
                    "title": [{"type": "text", "text": {"content": title}}],
                    "properties": payload.get("properties") or {
                        "Name": {"title": {}},
                        "Status": {"select": {"options": [{"name": "Todo"}, {"name": "In Progress"}, {"name": "Done"}]}},
                    },
                }
                resp = await self.http_client.post(f"{base_url}/databases", headers=headers, json=body, timeout=20.0)
                if resp.status_code not in (200, 201):
                    return ToolResult(success=False, result=None, error=f"Notion HTTP {resp.status_code}: {resp.text[:300]}")
                return ToolResult(success=True, result=resp.json())

            if action == "create_page":
                database_id = str(payload.get("database_id") or "").strip()
                title = str(payload.get("title") or "").strip()
                if not database_id:
                    return ToolResult(success=False, result=None, error="database_id is required")
                if not title:
                    return ToolResult(success=False, result=None, error="title is required")
                props = payload.get("properties") or {}
                props.setdefault("Name", {"title": [{"type": "text", "text": {"content": title}}]})
                body: Dict[str, Any] = {"parent": {"database_id": database_id}, "properties": props}
                content = str(payload.get("content") or "").strip()
                if content:
                    body["children"] = [{
                        "object": "block",
                        "type": "paragraph",
                        "paragraph": {"rich_text": [{"type": "text", "text": {"content": content}}]},
                    }]
                resp = await self.http_client.post(f"{base_url}/pages", headers=headers, json=body, timeout=20.0)
                if resp.status_code not in (200, 201):
                    return ToolResult(success=False, result=None, error=f"Notion HTTP {resp.status_code}: {resp.text[:300]}")
                return ToolResult(success=True, result=resp.json())

            if action == "update_page":
                page_id = str(payload.get("page_id") or "").strip()
                if not page_id:
                    return ToolResult(success=False, result=None, error="page_id is required")
                body: Dict[str, Any] = {}
                if isinstance(payload.get("properties"), dict):
                    body["properties"] = payload.get("properties")
                if "archived" in payload:
                    body["archived"] = bool(payload.get("archived"))
                if not body:
                    return ToolResult(success=False, result=None, error="nothing to update (properties|archived)")
                resp = await self.http_client.patch(f"{base_url}/pages/{page_id}", headers=headers, json=body, timeout=20.0)
                if resp.status_code != 200:
                    return ToolResult(success=False, result=None, error=f"Notion HTTP {resp.status_code}: {resp.text[:300]}")
                return ToolResult(success=True, result=resp.json())

            if action == "create_task":
                database_id = str(payload.get("database_id") or "").strip()
                title = str(payload.get("title") or "").strip()
                if not database_id:
                    return ToolResult(success=False, result=None, error="database_id is required")
                if not title:
                    return ToolResult(success=False, result=None, error="title is required")
                status = str(payload.get("status") or "Todo").strip()
                due_date = str(payload.get("due_date") or "").strip()
                props: Dict[str, Any] = {
                    "Name": {"title": [{"type": "text", "text": {"content": title}}]},
                    "Status": {"select": {"name": status}},
                }
                if due_date:
                    props["Due"] = {"date": {"start": due_date}}
                user_props = payload.get("properties")
                if isinstance(user_props, dict):
                    props.update(user_props)
                body = {"parent": {"database_id": database_id}, "properties": props}
                resp = await self.http_client.post(f"{base_url}/pages", headers=headers, json=body, timeout=20.0)
                if resp.status_code not in (200, 201):
                    return ToolResult(success=False, result=None, error=f"Notion HTTP {resp.status_code}: {resp.text[:300]}")
                return ToolResult(success=True, result=resp.json())

            return ToolResult(success=False, result=None, error=f"Unsupported action: {action}")
        except Exception as e:
            return ToolResult(success=False, result=None, error=f"Notion error: {str(e)}")

    async def _pieces_tool(self, args: Dict[str, Any]) -> ToolResult:
        """
        Pieces OS integration (local runtime on host machine).
        Default action returns processor status from Pieces OS.
        """
        payload = args or {}
        action = str(payload.get("action") or "status").strip().lower()
        base_url = str(
            payload.get("base_url")
            or os.getenv("PIECES_OS_URL", "http://host.docker.internal:39300")
        ).rstrip("/")

        if action in {"status", "workstream_status"}:
            path = "/workstream_pattern_engine/processors/status"
        elif action == "ping":
            path = "/workstream_pattern_engine/processors/status"
        else:
            return ToolResult(
                success=False,
                result=None,
                error=f"Unsupported action: {action}. Use status|ping",
            )

        url = f"{base_url}{path}"
        try:
            resp = await self.http_client.get(url, timeout=5.0)
            if resp.status_code != 200:
                return ToolResult(
                    success=False,
                    result=None,
                    error=f"Pieces OS HTTP {resp.status_code} at {url}",
                )
            data = resp.json()
            return ToolResult(
                success=True,
                result={
                    "provider": "pieces_os",
                    "url": url,
                    "status": "ok",
                    "data": data,
                },
            )
        except Exception as e:
            return ToolResult(
                success=False,
                result=None,
                error=f"Pieces OS unavailable: {str(e)}",
            )

    async def _calendar_tool(self, args: Dict[str, Any]) -> ToolResult:
        payload = args or {}
        action = str(payload.get("action") or "").strip()
        if not action:
            return ToolResult(success=False, result=None, error="Missing action")

        req = {
            "action": action,
            "workspace_id": str(payload.get("workspace_id") or "default"),
            "user_id": str(payload.get("user_id") or payload.get("agent_id") or "sofiia"),
            "account_id": payload.get("account_id"),
            "calendar_id": payload.get("calendar_id"),
            "params": payload.get("params") if isinstance(payload.get("params"), dict) else {},
        }
        headers = {
            "x-agent-id": str(payload.get("agent_id") or "sofiia"),
            "x-workspace-id": req["workspace_id"],
            "content-type": "application/json",
        }
        url = f"{self.calendar_service_url}/v1/tools/calendar"
        try:
            resp = await self.http_client.post(url, json=req, headers=headers, timeout=20.0)
            if resp.status_code != 200:
                return ToolResult(success=False, result=None, error=f"calendar-service HTTP {resp.status_code}: {resp.text[:300]}")
            data = resp.json()
            if isinstance(data, dict) and data.get("status") in {"failed", "error"}:
                return ToolResult(success=False, result=None, error=str(data.get("detail") or data.get("error") or "Calendar error"))
            return ToolResult(success=True, result=data)
        except Exception as e:
            return ToolResult(success=False, result=None, error=f"Calendar service unavailable: {e}")

    def _get_agent_email_client(self, agent_id: str):
        if agent_id in self._agent_email_clients:
            return self._agent_email_clients[agent_id]
        try:
            from tools.agent_email.agent_email import AgentEmailTool  # type: ignore
        except Exception as e:
            raise RuntimeError(f"AgentEmailTool import failed: {e}")
        client = AgentEmailTool(agent_id=agent_id)
        self._agent_email_clients[agent_id] = client
        return client

    def _get_browser_client(self, agent_id: str):
        if agent_id in self._browser_clients:
            return self._browser_clients[agent_id]
        try:
            from tools.browser_tool.browser_tool import BrowserTool  # type: ignore
        except Exception as e:
            raise RuntimeError(f"BrowserTool import failed: {e}")
        client = BrowserTool(agent_id=agent_id, headless=True)
        self._browser_clients[agent_id] = client
        return client

    def _get_safe_executor(self):
        if self._safe_code_executor is not None:
            return self._safe_code_executor
        try:
            from tools.safe_code_executor.safe_code_executor import SafeCodeExecutor  # type: ignore
        except Exception as e:
            raise RuntimeError(f"SafeCodeExecutor import failed: {e}")
        self._safe_code_executor = SafeCodeExecutor()
        return self._safe_code_executor

    def _get_secure_vault(self):
        if self._secure_vault is not None:
            return self._secure_vault
        try:
            from tools.secure_vault.secure_vault import SecureVault  # type: ignore
        except Exception as e:
            raise RuntimeError(f"SecureVault import failed: {e}")
        self._secure_vault = SecureVault()
        return self._secure_vault

    async def _agent_email_tool(self, args: Dict[str, Any]) -> ToolResult:
        payload = args or {}
        action = str(payload.get("action") or "").strip().lower()
        agent_id = str(payload.get("agent_id") or "sofiia")
        if not action:
            return ToolResult(success=False, result=None, error="Missing action")
        try:
            tool = self._get_agent_email_client(agent_id)
            if action == "create_inbox":
                return ToolResult(success=True, result=tool.create_inbox(
                    username=payload.get("username"),
                    domain=payload.get("domain"),
                    display_name=payload.get("display_name"),
                ))
            if action == "list_inboxes":
                return ToolResult(success=True, result={"inboxes": tool.list_inboxes()})
            if action == "delete_inbox":
                return ToolResult(success=True, result=tool.delete_inbox(inbox_id=payload.get("inbox_id")))
            if action == "send":
                to = payload.get("to") if isinstance(payload.get("to"), list) else []
                return ToolResult(success=True, result=tool.send(
                    to=to,
                    subject=str(payload.get("subject") or ""),
                    body=str(payload.get("body") or ""),
                    html=payload.get("html"),
                    attachments=payload.get("attachments") if isinstance(payload.get("attachments"), list) else None,
                    cc=payload.get("cc") if isinstance(payload.get("cc"), list) else None,
                    bcc=payload.get("bcc") if isinstance(payload.get("bcc"), list) else None,
                    inbox_id=payload.get("inbox_id"),
                ))
            if action == "receive":
                return ToolResult(success=True, result={
                    "emails": tool.receive(
                        unread_only=bool(payload.get("unread_only", True)),
                        limit=int(payload.get("limit", 20)),
                        query=payload.get("query"),
                        inbox_id=payload.get("inbox_id"),
                    )
                })
            if action == "analyze_email":
                email_obj = payload.get("email") if isinstance(payload.get("email"), dict) else {}
                return ToolResult(success=True, result=tool.analyze_and_extract(email_obj))
            return ToolResult(success=False, result=None, error=f"Unsupported action: {action}")
        except Exception as e:
            return ToolResult(success=False, result=None, error=f"Agent email tool error: {e}")

    async def _browser_tool(self, args: Dict[str, Any]) -> ToolResult:
        payload = args or {}
        action = str(payload.get("action") or "").strip().lower()
        agent_id = str(payload.get("agent_id") or "sofiia")
        if not action:
            return ToolResult(success=False, result=None, error="Missing action")
        try:
            tool = self._get_browser_client(agent_id)
            if action == "start_session":
                result = await asyncio.to_thread(
                    tool.start_session,
                    headless=payload.get("headless"),
                    proxy=payload.get("proxy"),
                    stealth=payload.get("stealth"),
                    restore_existing=bool(payload.get("restore_existing", True)),
                )
                return ToolResult(success=True, result=result)
            if action == "restore_session":
                result = await asyncio.to_thread(tool.restore_session, agent_id)
                return ToolResult(success=True, result=result)
            if action == "close_session":
                result = await asyncio.to_thread(tool.close_session)
                return ToolResult(success=True, result=result)
            if action == "goto":
                result = await asyncio.to_thread(tool.goto, str(payload.get("url") or ""))
                return ToolResult(success=True, result=result)
            if action == "act":
                result = await asyncio.to_thread(tool.act, str(payload.get("instruction") or ""))
                return ToolResult(success=True, result=result)
            if action == "extract":
                result = await asyncio.to_thread(
                    tool.extract,
                    instruction=str(payload.get("instruction") or ""),
                    schema=payload.get("schema") if isinstance(payload.get("schema"), dict) else None,
                )
                return ToolResult(success=True, result=result)
            if action == "observe":
                actions = await asyncio.to_thread(tool.observe, instruction=payload.get("instruction"))
                return ToolResult(success=True, result={"actions": actions})
            if action == "screenshot":
                shot = await asyncio.to_thread(tool.screenshot)
                if isinstance(shot, bytes):
                    return ToolResult(success=True, result={"status": "ok"}, file_base64=self._b64_from_bytes(shot), file_name="screenshot.png", file_mime="image/png")
                return ToolResult(success=True, result={"path": shot})
            if action == "fill_form":
                fields = payload.get("fields") if isinstance(payload.get("fields"), list) else []
                result = await asyncio.to_thread(tool.fill_form, fields)
                return ToolResult(success=True, result=result)
            if action == "wait_for":
                found = await asyncio.to_thread(
                    tool.wait_for,
                    str(payload.get("selector_or_text") or ""),
                    timeout=int(payload.get("timeout", 10)),
                )
                return ToolResult(success=True, result={"found": found})
            if action == "get_current_url":
                url = await asyncio.to_thread(tool.get_current_url)
                return ToolResult(success=True, result={"url": url})
            if action == "get_page_text":
                text = await asyncio.to_thread(tool.get_page_text)
                return ToolResult(success=True, result={"text": text})
            return ToolResult(success=False, result=None, error=f"Unsupported action: {action}")
        except Exception as e:
            return ToolResult(success=False, result=None, error=f"Browser tool error: {e}")

    async def _safe_code_executor_tool(self, args: Dict[str, Any]) -> ToolResult:
        payload = args or {}
        action = str(payload.get("action") or "").strip().lower()
        if not action:
            return ToolResult(success=False, result=None, error="Missing action")
        try:
            executor = self._get_safe_executor()
            if action == "validate":
                language = str(payload.get("language") or "")
                code = str(payload.get("code") or "")
                err = executor.validate(language, code)
                return ToolResult(success=err is None, result={"valid": err is None, "error": err})
            if action == "execute":
                return ToolResult(success=True, result=executor.execute(
                    language=str(payload.get("language") or ""),
                    code=str(payload.get("code") or ""),
                    stdin=payload.get("stdin"),
                    limits=payload.get("limits") if isinstance(payload.get("limits"), dict) else None,
                    context={"agent_id": payload.get("agent_id")},
                ))
            if action == "execute_async":
                job_id = executor.execute_async(
                    language=str(payload.get("language") or ""),
                    code=str(payload.get("code") or ""),
                    stdin=payload.get("stdin"),
                    limits=payload.get("limits") if isinstance(payload.get("limits"), dict) else None,
                    context={"agent_id": payload.get("agent_id")},
                )
                return ToolResult(success=True, result={"job_id": job_id})
            if action == "get_job_result":
                return ToolResult(success=True, result={"job": executor.get_job_result(str(payload.get("job_id") or ""))})
            if action == "get_stats":
                return ToolResult(success=True, result=executor.get_stats())
            if action == "kill_process":
                ok = executor.kill_process(str(payload.get("execution_id") or ""))
                return ToolResult(success=ok, result={"killed": ok}, error=None if ok else "execution_id not found")
            return ToolResult(success=False, result=None, error=f"Unsupported action: {action}")
        except Exception as e:
            return ToolResult(success=False, result=None, error=f"Safe code executor error: {e}")

    async def _secure_vault_tool(self, args: Dict[str, Any]) -> ToolResult:
        payload = args or {}
        action = str(payload.get("action") or "").strip().lower()
        agent_id = str(payload.get("agent_id") or "sofiia")
        if not action:
            return ToolResult(success=False, result=None, error="Missing action")
        try:
            vault = self._get_secure_vault()
            if action == "store":
                return ToolResult(success=True, result=vault.store(
                    agent_id=agent_id,
                    service=str(payload.get("service") or ""),
                    credential_name=str(payload.get("credential_name") or ""),
                    value=payload.get("value"),
                    ttl_seconds=int(payload["ttl_seconds"]) if payload.get("ttl_seconds") is not None else None,
                ))
            if action == "get":
                value = vault.get(
                    agent_id=agent_id,
                    service=str(payload.get("service") or ""),
                    credential_name=str(payload.get("credential_name") or ""),
                )
                return ToolResult(success=True, result={"value": value, "found": value is not None})
            if action == "delete":
                return ToolResult(success=True, result=vault.delete(
                    agent_id=agent_id,
                    service=str(payload.get("service") or ""),
                    credential_name=str(payload.get("credential_name") or ""),
                ))
            if action == "list":
                listed = vault.list(agent_id=agent_id, service=payload.get("service"))
                key = "credentials" if payload.get("service") else "services"
                return ToolResult(success=True, result={key: listed})
            if action == "check_expiring":
                return ToolResult(success=True, result={"items": vault.check_expiring(agent_id, days=int(payload.get("days", 7)))})
            if action == "vacuum":
                return ToolResult(success=True, result=vault.vacuum(agent_id))
            return ToolResult(success=False, result=None, error=f"Unsupported action: {action}")
        except Exception as e:
            return ToolResult(success=False, result=None, error=f"Secure vault tool error: {e}")

    async def _kb_tool(self, args: Dict[str, Any]) -> ToolResult:
        """
        Knowledge Base Tool (read-only).
        Actions: search, snippets, open, sources.
        """
        payload = args or {}
        action = str(payload.get("action") or "search").strip().lower()
        params = payload.get("params") if isinstance(payload.get("params"), dict) else {}

        repo_root = Path(os.getenv("REPO_ROOT", "/app")).resolve()
        default_paths = ["docs", "runbooks", "ops", "adr", "specs"]
        excluded_dirs = {"node_modules", "vendor", "dist", "build", ".git", "__pycache__", ".pytest_cache", "venv", ".venv"}
        allowed_ext = {".md", ".txt", ".yaml", ".yml", ".json"}

        def _safe_roots(paths: Any) -> List[Path]:
            roots: List[Path] = []
            rels = paths if isinstance(paths, list) and paths else default_paths
            for rel in rels:
                if not isinstance(rel, str) or not rel.strip():
                    continue
                cand = (repo_root / rel.strip()).resolve()
                if str(cand).startswith(str(repo_root)) and cand.exists() and cand.is_dir():
                    roots.append(cand)
            return roots

        def _is_excluded(path: Path) -> bool:
            return any(part in excluded_dirs for part in path.parts)

        def _iter_candidates(paths: Any, file_glob: str) -> List[Path]:
            roots = _safe_roots(paths)
            out: List[Path] = []
            pattern = file_glob or "**/*.md"
            for root in roots:
                try:
                    for p in root.glob(pattern):
                        if not p.is_file():
                            continue
                        if p.suffix.lower() not in allowed_ext:
                            continue
                        if _is_excluded(p):
                            continue
                        out.append(p)
                except Exception:
                    continue
            return out

        def _read_text(path: Path, max_bytes: int = 120000) -> str:
            try:
                return path.read_text(encoding="utf-8", errors="ignore")[:max_bytes]
            except Exception:
                return ""

        def _tokens(query: str) -> List[str]:
            return [t for t in re.findall(r"\w+", (query or "").lower()) if len(t) > 1]

        def _rel(path: Path) -> str:
            return str(path.resolve().relative_to(repo_root))

        if action == "sources":
            rows = []
            for root in _safe_roots(params.get("paths")):
                count = 0
                for p in root.rglob("*"):
                    if p.is_file() and p.suffix.lower() in allowed_ext and not _is_excluded(p):
                        count += 1
                rows.append({"path": _rel(root), "file_count": count, "status": "indexed"})
            return ToolResult(success=True, result={"summary": f"Found {len(rows)} indexed sources", "sources": rows, "allowed_paths": default_paths})

        if action == "open":
            raw_path = str(params.get("path") or "").strip()
            if not raw_path:
                return ToolResult(success=False, result=None, error="path is required")
            target = (repo_root / raw_path).resolve()
            if not str(target).startswith(str(repo_root)):
                return ToolResult(success=False, result=None, error="Path traversal blocked")
            if not any(str(target).startswith(str(root)) for root in _safe_roots(default_paths)):
                return ToolResult(success=False, result=None, error="Path not in allowed directories")
            if not target.exists() or not target.is_file():
                return ToolResult(success=False, result=None, error="File not found")

            start_line = max(1, int(params.get("start_line", 1)))
            end_line_raw = params.get("end_line")
            end_line = int(end_line_raw) if end_line_raw is not None else None
            max_bytes = int(params.get("max_bytes", 200000))
            content = _read_text(target, max_bytes=max_bytes)
            lines = content.splitlines()
            sliced = lines[start_line - 1:end_line] if end_line else lines[start_line - 1:]
            return ToolResult(
                success=True,
                result={
                    "path": _rel(target),
                    "start_line": start_line,
                    "end_line": end_line or (start_line + len(sliced) - 1),
                    "total_lines": len(lines),
                    "content": "\n".join(sliced),
                    "truncated": len(content) >= max_bytes,
                },
            )

        if action in {"search", "snippets"}:
            query = str(params.get("query") or "").strip()
            if not query:
                return ToolResult(success=False, result=None, error="query is required")

            limit = max(1, min(int(params.get("limit", 20 if action == "search" else 8)), 100))
            context_lines = max(0, min(int(params.get("context_lines", 4)), 20))
            max_chars = max(100, min(int(params.get("max_chars_per_snippet", 800)), 5000))
            candidates = _iter_candidates(params.get("paths"), params.get("file_glob", "**/*.md"))
            q_tokens = _tokens(query)

            ranked = []
            for p in candidates:
                text = _read_text(p)
                if not text:
                    continue
                lower = text.lower()
                score = float(sum(lower.count(tok) for tok in q_tokens))
                if score <= 0:
                    continue
                ranked.append((score, p, text))
            ranked.sort(key=lambda x: x[0], reverse=True)

            if action == "search":
                results = []
                for score, p, text in ranked[:limit]:
                    highlights: List[str] = []
                    lower = text.lower()
                    for tok in q_tokens[:5]:
                        idx = lower.find(tok)
                        if idx >= 0:
                            s = max(0, idx - 30)
                            e = min(len(text), idx + len(tok) + 30)
                            highlights.append("..." + text[s:e].replace("\n", " ") + "...")
                    results.append({"path": _rel(p), "score": round(score, 2), "highlights": highlights[:5]})
                return ToolResult(
                    success=True,
                    result={"summary": f"Found {len(results)} results for '{query}'", "results": results, "query": query, "count": len(results)},
                )

            snippets = []
            for score, p, text in ranked:
                lines = text.splitlines()
                lower_lines = [ln.lower() for ln in lines]
                for i, ln in enumerate(lower_lines):
                    if any(tok in ln for tok in q_tokens):
                        s = max(0, i - context_lines)
                        e = min(len(lines), i + context_lines + 1)
                        snippet = "\n".join(lines[s:e])
                        if len(snippet) > max_chars:
                            snippet = snippet[:max_chars] + "..."
                        snippets.append({"path": _rel(p), "score": round(score, 2), "lines": f"L{s+1}-L{e}", "text": snippet})
                        if len(snippets) >= limit:
                            break
                if len(snippets) >= limit:
                    break
            return ToolResult(
                success=True,
                result={"summary": f"Found {len(snippets)} snippets for '{query}'", "results": snippets, "query": query, "count": len(snippets)},
            )

        return ToolResult(success=False, result=None, error=f"Unknown action: {action}")

    def _file_csv_create(self, args: Dict[str, Any]) -> ToolResult:
        file_name = self._sanitize_file_name(args.get("file_name"), "export.csv", force_ext=".csv")
        headers = args.get("headers") or []
        rows_raw = args.get("rows") or []
        rows = self._normalize_rows(rows_raw, headers=headers if headers else None)
        if rows and not headers and isinstance(rows_raw[0], dict):
            headers = list(rows_raw[0].keys())
            rows = self._normalize_rows(rows_raw, headers=headers)

        sio = StringIO(newline="")
        writer = csv.writer(sio)
        if headers:
            writer.writerow(headers)
        for row in rows:
            writer.writerow(row)

        data = sio.getvalue().encode("utf-8")
        return ToolResult(
            success=True,
            result={"message": f"CSV created: {file_name}"},
            file_base64=self._b64_from_bytes(data),
            file_name=file_name,
            file_mime="text/csv",
        )

    def _file_csv_update(self, args: Dict[str, Any]) -> ToolResult:
        src_b64 = args.get("file_base64")
        if not src_b64:
            return ToolResult(success=False, result=None, error="file_base64 is required for csv_update")
        file_name = self._sanitize_file_name(args.get("file_name"), "updated.csv", force_ext=".csv")
        operation = str(args.get("operation") or "append").strip().lower()
        if operation not in {"append", "replace"}:
            return ToolResult(success=False, result=None, error="operation must be append|replace")

        headers = args.get("headers") or []
        rows_raw = args.get("rows") or []
        rows = self._normalize_rows(rows_raw, headers=headers if headers else None)
        if rows and not headers and isinstance(rows_raw[0], dict):
            headers = list(rows_raw[0].keys())
            rows = self._normalize_rows(rows_raw, headers=headers)

        existing_rows: List[List[str]] = []
        text = self._bytes_from_b64(src_b64).decode("utf-8")
        if text.strip():
            existing_rows = [list(r) for r in csv.reader(StringIO(text))]

        out_rows: List[List[Any]] = []
        if operation == "replace":
            if headers:
                out_rows.append(headers)
            out_rows.extend(rows)
        else:
            if existing_rows:
                out_rows.extend(existing_rows)
            elif headers:
                out_rows.append(headers)
            out_rows.extend(rows)

        sio = StringIO(newline="")
        writer = csv.writer(sio)
        for row in out_rows:
            writer.writerow(row)
        data = sio.getvalue().encode("utf-8")
        return ToolResult(
            success=True,
            result={"message": f"CSV updated: {file_name}"},
            file_base64=self._b64_from_bytes(data),
            file_name=file_name,
            file_mime="text/csv",
        )

    @staticmethod
    def _rows_to_objects(rows_raw: Any, headers: Optional[List[str]] = None) -> List[Dict[str, Any]]:
        if not isinstance(rows_raw, list):
            return []
        result: List[Dict[str, Any]] = []
        for idx, row in enumerate(rows_raw):
            if isinstance(row, dict):
                result.append(dict(row))
                continue
            if isinstance(row, list):
                if headers:
                    obj = {str(headers[i]): row[i] if i < len(row) else None for i in range(len(headers))}
                else:
                    obj = {f"col_{i+1}": v for i, v in enumerate(row)}
                result.append(obj)
                continue
            key = headers[0] if headers else "value"
            result.append({str(key): row})
        return result

    def _file_ods_create(self, args: Dict[str, Any]) -> ToolResult:
        from odf.opendocument import OpenDocumentSpreadsheet
        from odf.table import Table, TableCell, TableRow
        from odf.text import P

        file_name = self._sanitize_file_name(args.get("file_name"), "sheet.ods", force_ext=".ods")
        headers = args.get("headers") or []
        rows_raw = args.get("rows") or []
        rows = self._normalize_rows(rows_raw, headers=headers if headers else None)
        if rows and not headers and isinstance(rows_raw[0], dict):
            headers = list(rows_raw[0].keys())
            rows = self._normalize_rows(rows_raw, headers=headers)

        doc = OpenDocumentSpreadsheet()
        table = Table(name=str(args.get("sheet_name") or "Sheet1"))

        if headers:
            hrow = TableRow()
            for value in headers:
                cell = TableCell(valuetype="string")
                cell.addElement(P(text=str(value)))
                hrow.addElement(cell)
            table.addElement(hrow)

        for row in rows:
            trow = TableRow()
            for value in row:
                cell = TableCell(valuetype="string")
                cell.addElement(P(text="" if value is None else str(value)))
                trow.addElement(cell)
            table.addElement(trow)

        doc.spreadsheet.addElement(table)
        with tempfile.NamedTemporaryFile(suffix=".ods") as tmp:
            doc.save(tmp.name)
            tmp.seek(0)
            payload = tmp.read()

        return ToolResult(
            success=True,
            result={"message": f"ODS created: {file_name}"},
            file_base64=self._b64_from_bytes(payload),
            file_name=file_name,
            file_mime="application/vnd.oasis.opendocument.spreadsheet",
        )

    def _file_ods_update(self, args: Dict[str, Any]) -> ToolResult:
        from odf.opendocument import OpenDocumentSpreadsheet, load
        from odf.table import Table, TableCell, TableRow
        from odf.text import P

        src_b64 = args.get("file_base64")
        if not src_b64:
            return ToolResult(success=False, result=None, error="file_base64 is required for ods_update")
        file_name = self._sanitize_file_name(args.get("file_name"), "updated.ods", force_ext=".ods")
        operation = str(args.get("operation") or "append").strip().lower()
        if operation not in {"append", "replace"}:
            return ToolResult(success=False, result=None, error="operation must be append|replace")

        headers = args.get("headers") or []
        rows_raw = args.get("rows") or []
        rows = self._normalize_rows(rows_raw, headers=headers if headers else None)
        if rows and not headers and isinstance(rows_raw[0], dict):
            headers = list(rows_raw[0].keys())
            rows = self._normalize_rows(rows_raw, headers=headers)

        with tempfile.NamedTemporaryFile(suffix=".ods") as src:
            src.write(self._bytes_from_b64(src_b64))
            src.flush()
            doc = load(src.name)

        # Rebuild first table to keep update deterministic.
        tables = doc.spreadsheet.getElementsByType(Table)
        existing: List[List[str]] = []
        if tables and operation == "append":
            first = tables[0]
            for r in first.getElementsByType(TableRow):
                vals = []
                for c in r.getElementsByType(TableCell):
                    text_nodes = c.getElementsByType(P)
                    vals.append("".join((p.firstChild.data if p.firstChild else "") for p in text_nodes))
                existing.append(vals)
            doc.spreadsheet.removeChild(first)
        elif tables:
            doc.spreadsheet.removeChild(tables[0])

        table = Table(name=str(args.get("sheet_name") or "Sheet1"))
        out_rows: List[List[Any]] = []
        if operation == "append" and existing:
            out_rows.extend(existing)
            out_rows.extend(rows)
        else:
            if headers:
                out_rows.append(headers)
            out_rows.extend(rows)

        for row in out_rows:
            trow = TableRow()
            for value in row:
                cell = TableCell(valuetype="string")
                cell.addElement(P(text="" if value is None else str(value)))
                trow.addElement(cell)
            table.addElement(trow)

        doc.spreadsheet.addElement(table)
        with tempfile.NamedTemporaryFile(suffix=".ods") as dst:
            doc.save(dst.name)
            dst.seek(0)
            payload = dst.read()

        return ToolResult(
            success=True,
            result={"message": f"ODS updated: {file_name}"},
            file_base64=self._b64_from_bytes(payload),
            file_name=file_name,
            file_mime="application/vnd.oasis.opendocument.spreadsheet",
        )

    def _file_parquet_create(self, args: Dict[str, Any]) -> ToolResult:
        import pyarrow as pa
        import pyarrow.parquet as pq

        file_name = self._sanitize_file_name(args.get("file_name"), "data.parquet", force_ext=".parquet")
        headers = args.get("headers") or []
        rows_raw = args.get("rows") or []
        objects = self._rows_to_objects(rows_raw, headers=headers if headers else None)
        table = pa.Table.from_pylist(objects if objects else [{"value": None}])
        out = BytesIO()
        pq.write_table(table, out)
        payload = out.getvalue()

        return ToolResult(
            success=True,
            result={"message": f"Parquet created: {file_name}"},
            file_base64=self._b64_from_bytes(payload),
            file_name=file_name,
            file_mime="application/vnd.apache.parquet",
        )

    def _file_parquet_update(self, args: Dict[str, Any]) -> ToolResult:
        import pyarrow as pa
        import pyarrow.parquet as pq

        src_b64 = args.get("file_base64")
        if not src_b64:
            return ToolResult(success=False, result=None, error="file_base64 is required for parquet_update")
        file_name = self._sanitize_file_name(args.get("file_name"), "updated.parquet", force_ext=".parquet")
        operation = str(args.get("operation") or "append").strip().lower()
        if operation not in {"append", "replace"}:
            return ToolResult(success=False, result=None, error="operation must be append|replace")

        headers = args.get("headers") or []
        rows_raw = args.get("rows") or []
        new_rows = self._rows_to_objects(rows_raw, headers=headers if headers else None)

        existing_rows: List[Dict[str, Any]] = []
        if operation == "append":
            table = pq.read_table(BytesIO(self._bytes_from_b64(src_b64)))
            existing_rows = table.to_pylist()

        merged = new_rows if operation == "replace" else (existing_rows + new_rows)
        table = pa.Table.from_pylist(merged if merged else [{"value": None}])
        out = BytesIO()
        pq.write_table(table, out)
        payload = out.getvalue()

        return ToolResult(
            success=True,
            result={"message": f"Parquet updated: {file_name}"},
            file_base64=self._b64_from_bytes(payload),
            file_name=file_name,
            file_mime="application/vnd.apache.parquet",
        )

    def _file_json_export(self, args: Dict[str, Any]) -> ToolResult:
        file_name = self._sanitize_file_name(args.get("file_name"), "export.json", force_ext=".json")
        content = args.get("content")
        indent = int(args.get("indent") or 2)
        payload = json.dumps(content, indent=indent, ensure_ascii=False).encode("utf-8")
        return ToolResult(
            success=True,
            result={"message": f"JSON exported: {file_name}"},
            file_base64=self._b64_from_bytes(payload),
            file_name=file_name,
            file_mime="application/json",
        )

    @staticmethod
    def _stringify_text_payload(args: Dict[str, Any], key: str = "text") -> str:
        value = args.get(key)
        if value is None and key != "content":
            value = args.get("content")
        if value is None:
            return ""
        if isinstance(value, (dict, list)):
            return json.dumps(value, ensure_ascii=False, indent=2)
        return str(value)

    def _file_text_create(self, args: Dict[str, Any]) -> ToolResult:
        file_name = self._sanitize_file_name(args.get("file_name"), "note.txt", force_ext=".txt")
        text = self._stringify_text_payload(args, key="text")
        data = text.encode("utf-8")
        return ToolResult(
            success=True,
            result={"message": f"Text file created: {file_name}"},
            file_base64=self._b64_from_bytes(data),
            file_name=file_name,
            file_mime="text/plain",
        )

    def _file_text_update(self, args: Dict[str, Any]) -> ToolResult:
        src_b64 = args.get("file_base64")
        if not src_b64:
            return ToolResult(success=False, result=None, error="file_base64 is required for text_update")
        file_name = self._sanitize_file_name(args.get("file_name"), "updated.txt", force_ext=".txt")
        operation = str(args.get("operation") or "append").strip().lower()
        if operation not in {"append", "replace"}:
            return ToolResult(success=False, result=None, error="operation must be append|replace")

        incoming = self._stringify_text_payload(args, key="text")
        existing = self._bytes_from_b64(src_b64).decode("utf-8")
        updated = incoming if operation == "replace" else f"{existing}{incoming}"
        data = updated.encode("utf-8")
        return ToolResult(
            success=True,
            result={"message": f"Text file updated: {file_name}"},
            file_base64=self._b64_from_bytes(data),
            file_name=file_name,
            file_mime="text/plain",
        )

    def _file_markdown_create(self, args: Dict[str, Any]) -> ToolResult:
        file_name = self._sanitize_file_name(args.get("file_name"), "document.md", force_ext=".md")
        text = self._stringify_text_payload(args, key="text")
        data = text.encode("utf-8")
        return ToolResult(
            success=True,
            result={"message": f"Markdown created: {file_name}"},
            file_base64=self._b64_from_bytes(data),
            file_name=file_name,
            file_mime="text/markdown",
        )

    def _file_markdown_update(self, args: Dict[str, Any]) -> ToolResult:
        src_b64 = args.get("file_base64")
        if not src_b64:
            return ToolResult(success=False, result=None, error="file_base64 is required for markdown_update")
        file_name = self._sanitize_file_name(args.get("file_name"), "updated.md", force_ext=".md")
        operation = str(args.get("operation") or "append").strip().lower()
        if operation not in {"append", "replace"}:
            return ToolResult(success=False, result=None, error="operation must be append|replace")

        incoming = self._stringify_text_payload(args, key="text")
        existing = self._bytes_from_b64(src_b64).decode("utf-8")
        updated = incoming if operation == "replace" else f"{existing}{incoming}"
        data = updated.encode("utf-8")
        return ToolResult(
            success=True,
            result={"message": f"Markdown updated: {file_name}"},
            file_base64=self._b64_from_bytes(data),
            file_name=file_name,
            file_mime="text/markdown",
        )

    def _file_xml_export(self, args: Dict[str, Any]) -> ToolResult:
        file_name = self._sanitize_file_name(args.get("file_name"), "export.xml", force_ext=".xml")
        xml = self._stringify_text_payload(args, key="xml")
        data = xml.encode("utf-8")
        return ToolResult(
            success=True,
            result={"message": f"XML exported: {file_name}"},
            file_base64=self._b64_from_bytes(data),
            file_name=file_name,
            file_mime="application/xml",
        )

    def _file_html_export(self, args: Dict[str, Any]) -> ToolResult:
        file_name = self._sanitize_file_name(args.get("file_name"), "export.html", force_ext=".html")
        html = self._stringify_text_payload(args, key="html")
        data = html.encode("utf-8")
        return ToolResult(
            success=True,
            result={"message": f"HTML exported: {file_name}"},
            file_base64=self._b64_from_bytes(data),
            file_name=file_name,
            file_mime="text/html",
        )

    @staticmethod
    def _image_format_for_name(file_name: str, fallback: str = "PNG") -> str:
        suffix = PurePath(file_name).suffix.lower()
        mapping = {
            ".png": "PNG",
            ".jpg": "JPEG",
            ".jpeg": "JPEG",
            ".webp": "WEBP",
            ".gif": "GIF",
            ".bmp": "BMP",
            ".tif": "TIFF",
            ".tiff": "TIFF",
        }
        return mapping.get(suffix, fallback)

    @staticmethod
    def _mime_for_image_format(fmt: str) -> str:
        mapping = {
            "PNG": "image/png",
            "JPEG": "image/jpeg",
            "WEBP": "image/webp",
            "GIF": "image/gif",
            "BMP": "image/bmp",
            "TIFF": "image/tiff",
        }
        return mapping.get(fmt.upper(), "application/octet-stream")

    def _file_image_create(self, args: Dict[str, Any]) -> ToolResult:
        from PIL import Image, ImageDraw

        file_name = self._sanitize_file_name(args.get("file_name"), "image.png")
        fmt = self._image_format_for_name(file_name, fallback=str(args.get("format") or "PNG"))
        width = max(1, int(args.get("width") or 1024))
        height = max(1, int(args.get("height") or 1024))
        color = args.get("background_color") or args.get("color") or "white"

        image = Image.new("RGB", (width, height), color=color)
        text = args.get("text")
        if text:
            draw = ImageDraw.Draw(image)
            draw.text((20, 20), str(text), fill=args.get("text_color") or "black")

        out = BytesIO()
        image.save(out, format=fmt)
        payload = out.getvalue()
        return ToolResult(
            success=True,
            result={"message": f"Image created: {file_name}"},
            file_base64=self._b64_from_bytes(payload),
            file_name=file_name,
            file_mime=self._mime_for_image_format(fmt),
        )

    def _file_image_edit(self, args: Dict[str, Any]) -> ToolResult:
        from PIL import Image, ImageDraw

        src_b64 = args.get("file_base64")
        operations = args.get("operations") or []
        if not src_b64:
            return ToolResult(success=False, result=None, error="file_base64 is required for image_edit")
        if not isinstance(operations, list) or not operations:
            return ToolResult(success=False, result=None, error="operations must be non-empty array")

        file_name = self._sanitize_file_name(args.get("file_name"), "edited.png")
        fmt = self._image_format_for_name(file_name, fallback=str(args.get("format") or "PNG"))
        image = Image.open(BytesIO(self._bytes_from_b64(src_b64)))

        for op in operations:
            if not isinstance(op, dict):
                return ToolResult(success=False, result=None, error="Each operation must be object")
            op_type = str(op.get("type") or "").strip().lower()
            if op_type == "resize":
                width = max(1, int(op.get("width") or image.width))
                height = max(1, int(op.get("height") or image.height))
                image = image.resize((width, height))
            elif op_type == "crop":
                left = int(op.get("left") or 0)
                top = int(op.get("top") or 0)
                right = int(op.get("right") or image.width)
                bottom = int(op.get("bottom") or image.height)
                image = image.crop((left, top, right, bottom))
            elif op_type == "rotate":
                angle = float(op.get("angle") or 0)
                image = image.rotate(angle, expand=bool(op.get("expand", True)))
            elif op_type == "flip_horizontal":
                image = image.transpose(Image.FLIP_LEFT_RIGHT)
            elif op_type == "flip_vertical":
                image = image.transpose(Image.FLIP_TOP_BOTTOM)
            elif op_type == "draw_text":
                draw = ImageDraw.Draw(image)
                x = int(op.get("x") or 0)
                y = int(op.get("y") or 0)
                draw.text((x, y), str(op.get("text") or ""), fill=op.get("color") or "black")
            else:
                return ToolResult(success=False, result=None, error=f"Unsupported image_edit operation: {op_type}")

        out = BytesIO()
        image.save(out, format=fmt)
        payload = out.getvalue()
        return ToolResult(
            success=True,
            result={"message": f"Image edited: {file_name}"},
            file_base64=self._b64_from_bytes(payload),
            file_name=file_name,
            file_mime=self._mime_for_image_format(fmt),
        )

    def _file_image_convert(self, args: Dict[str, Any]) -> ToolResult:
        from PIL import Image

        src_b64 = args.get("file_base64")
        if not src_b64:
            return ToolResult(success=False, result=None, error="file_base64 is required for image_convert")
        file_name = self._sanitize_file_name(args.get("file_name"), "converted.png")
        fmt = str(args.get("target_format") or self._image_format_for_name(file_name)).upper()
        if fmt == "JPG":
            fmt = "JPEG"

        image = Image.open(BytesIO(self._bytes_from_b64(src_b64)))
        if fmt in {"JPEG"} and image.mode not in {"RGB", "L"}:
            image = image.convert("RGB")

        out = BytesIO()
        save_kwargs: Dict[str, Any] = {}
        if fmt in {"JPEG", "WEBP"} and args.get("quality") is not None:
            save_kwargs["quality"] = int(args.get("quality"))
        image.save(out, format=fmt, **save_kwargs)
        payload = out.getvalue()
        return ToolResult(
            success=True,
            result={"message": f"Image converted: {file_name}"},
            file_base64=self._b64_from_bytes(payload),
            file_name=file_name,
            file_mime=self._mime_for_image_format(fmt),
        )

    def _file_image_bundle(self, args: Dict[str, Any]) -> ToolResult:
        file_name = self._sanitize_file_name(args.get("file_name"), "images.zip", force_ext=".zip")
        images = args.get("images") or args.get("entries") or []
        if not isinstance(images, list) or not images:
            return ToolResult(success=False, result=None, error="images must be non-empty array")

        out = BytesIO()
        with ZipFile(out, mode="w", compression=ZIP_DEFLATED) as zf:
            for idx, item in enumerate(images, start=1):
                if not isinstance(item, dict):
                    return ToolResult(success=False, result=None, error=f"images[{idx-1}] must be object")
                src_b64 = item.get("file_base64")
                if not src_b64:
                    return ToolResult(success=False, result=None, error=f"images[{idx-1}].file_base64 is required")
                in_name = self._sanitize_file_name(item.get("file_name"), f"image_{idx}.bin")
                zf.writestr(in_name, self._bytes_from_b64(src_b64))

        payload = out.getvalue()
        return ToolResult(
            success=True,
            result={"message": f"Image bundle created: {file_name}"},
            file_base64=self._b64_from_bytes(payload),
            file_name=file_name,
            file_mime="application/zip",
        )

    @staticmethod
    def _strip_ns(tag: str) -> str:
        return tag.split("}", 1)[1] if "}" in tag else tag

    @staticmethod
    def _safe_int(value: Any, default: int = 0) -> int:
        try:
            text = str(value).strip()
            if text.endswith("px"):
                text = text[:-2]
            return int(float(text))
        except Exception:
            return default

    @staticmethod
    def _safe_float(value: Any, default: float = 0.0) -> float:
        try:
            text = str(value).strip()
            if text.endswith("px"):
                text = text[:-2]
            return float(text)
        except Exception:
            return default

    @staticmethod
    def _svg_style_map(elem: Any) -> Dict[str, str]:
        style = str(elem.attrib.get("style") or "")
        out: Dict[str, str] = {}
        for chunk in style.split(";"):
            if ":" not in chunk:
                continue
            k, v = chunk.split(":", 1)
            out[k.strip()] = v.strip()
        return out

    def _svg_paint(self, elem: Any, key: str, default: Optional[str]) -> Optional[str]:
        style = self._svg_style_map(elem)
        value = elem.attrib.get(key, style.get(key, default))
        if value is None:
            return None
        text = str(value).strip()
        if not text or text.lower() == "none":
            return None
        return text

    @staticmethod
    def _svg_color(value: Optional[str], fallback: Optional[tuple[int, int, int]] = None) -> Optional[tuple[int, int, int]]:
        if value is None:
            return fallback
        try:
            from PIL import ImageColor
            return ImageColor.getrgb(value)
        except Exception:
            return fallback

    @staticmethod
    def _svg_points(raw: Any) -> List[tuple[float, float]]:
        text = str(raw or "").replace(",", " ")
        nums: List[float] = []
        for token in text.split():
            try:
                nums.append(float(token))
            except Exception:
                continue
        pts: List[tuple[float, float]] = []
        for i in range(0, len(nums) - 1, 2):
            pts.append((nums[i], nums[i + 1]))
        return pts

    def _file_svg_export(self, args: Dict[str, Any]) -> ToolResult:
        file_name = self._sanitize_file_name(args.get("file_name"), "image.svg", force_ext=".svg")
        svg_raw = args.get("svg")
        if svg_raw is not None:
            payload = str(svg_raw).encode("utf-8")
            return ToolResult(
                success=True,
                result={"message": f"SVG exported: {file_name}"},
                file_base64=self._b64_from_bytes(payload),
                file_name=file_name,
                file_mime="image/svg+xml",
            )

        width = max(1, int(args.get("width") or 1024))
        height = max(1, int(args.get("height") or 1024))
        bg = str(args.get("background_color") or "white")
        text = xml_escape(str(args.get("text") or ""))
        text_color = str(args.get("text_color") or "black")
        text_x = int(args.get("text_x") or 20)
        text_y = int(args.get("text_y") or 40)

        # Minimal deterministic SVG template for safe generation.
        svg = (
            f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" '
            f'viewBox="0 0 {width} {height}">'
            f'<rect x="0" y="0" width="{width}" height="{height}" fill="{bg}" />'
            f'<text x="{text_x}" y="{text_y}" fill="{text_color}">{text}</text>'
            f"</svg>"
        )
        payload = svg.encode("utf-8")
        return ToolResult(
            success=True,
            result={"message": f"SVG exported: {file_name}"},
            file_base64=self._b64_from_bytes(payload),
            file_name=file_name,
            file_mime="image/svg+xml",
        )

    def _file_svg_to_png(self, args: Dict[str, Any]) -> ToolResult:
        from PIL import Image, ImageDraw

        file_name = self._sanitize_file_name(args.get("file_name"), "converted.png", force_ext=".png")
        src_b64 = args.get("file_base64")
        svg_text = args.get("svg")
        if src_b64:
            svg_text = self._bytes_from_b64(src_b64).decode("utf-8", errors="ignore")
        if not svg_text:
            return ToolResult(success=False, result=None, error="file_base64 or svg is required for svg_to_png")

        try:
            root = ET.fromstring(svg_text)
        except Exception as exc:
            return ToolResult(success=False, result=None, error=f"Invalid SVG: {exc}")

        width = self._safe_int(root.attrib.get("width"), 1024)
        height = self._safe_int(root.attrib.get("height"), 1024)
        width = max(1, width)
        height = max(1, height)

        image = Image.new("RGBA", (width, height), color="white")
        draw = ImageDraw.Draw(image)

        for elem in root.iter():
            tag = self._strip_ns(elem.tag)
            if tag == "rect":
                x = self._safe_int(elem.attrib.get("x"), 0)
                y = self._safe_int(elem.attrib.get("y"), 0)
                w = self._safe_int(elem.attrib.get("width"), width)
                h = self._safe_int(elem.attrib.get("height"), height)
                fill = self._svg_paint(elem, "fill", "white")
                stroke = self._svg_paint(elem, "stroke", None)
                stroke_width = max(1, self._safe_int(elem.attrib.get("stroke-width"), 1))
                color = self._svg_color(fill, (255, 255, 255) if fill else None)
                outline = None
                if stroke:
                    outline = self._svg_color(stroke, (0, 0, 0))
                draw.rectangle([x, y, x + max(0, w), y + max(0, h)], fill=color, outline=outline, width=stroke_width)
            elif tag == "circle":
                cx = self._safe_float(elem.attrib.get("cx"), width / 2.0)
                cy = self._safe_float(elem.attrib.get("cy"), height / 2.0)
                r = max(0.0, self._safe_float(elem.attrib.get("r"), 0.0))
                fill = self._svg_paint(elem, "fill", None)
                stroke = self._svg_paint(elem, "stroke", None)
                stroke_width = max(1, self._safe_int(elem.attrib.get("stroke-width"), 1))
                fill_color = self._svg_color(fill, None)
                outline = self._svg_color(stroke, None)
                draw.ellipse([cx - r, cy - r, cx + r, cy + r], fill=fill_color, outline=outline, width=stroke_width)
            elif tag == "ellipse":
                cx = self._safe_float(elem.attrib.get("cx"), width / 2.0)
                cy = self._safe_float(elem.attrib.get("cy"), height / 2.0)
                rx = max(0.0, self._safe_float(elem.attrib.get("rx"), 0.0))
                ry = max(0.0, self._safe_float(elem.attrib.get("ry"), 0.0))
                fill = self._svg_paint(elem, "fill", None)
                stroke = self._svg_paint(elem, "stroke", None)
                stroke_width = max(1, self._safe_int(elem.attrib.get("stroke-width"), 1))
                fill_color = self._svg_color(fill, None)
                outline = self._svg_color(stroke, None)
                draw.ellipse([cx - rx, cy - ry, cx + rx, cy + ry], fill=fill_color, outline=outline, width=stroke_width)
            elif tag == "line":
                x1 = self._safe_float(elem.attrib.get("x1"), 0.0)
                y1 = self._safe_float(elem.attrib.get("y1"), 0.0)
                x2 = self._safe_float(elem.attrib.get("x2"), 0.0)
                y2 = self._safe_float(elem.attrib.get("y2"), 0.0)
                stroke = self._svg_paint(elem, "stroke", "black")
                stroke_width = max(1, self._safe_int(elem.attrib.get("stroke-width"), 1))
                color = self._svg_color(stroke, (0, 0, 0)) or (0, 0, 0)
                draw.line([(x1, y1), (x2, y2)], fill=color, width=stroke_width)
            elif tag == "polyline":
                points = self._svg_points(elem.attrib.get("points"))
                if len(points) >= 2:
                    stroke = self._svg_paint(elem, "stroke", "black")
                    stroke_width = max(1, self._safe_int(elem.attrib.get("stroke-width"), 1))
                    color = self._svg_color(stroke, (0, 0, 0)) or (0, 0, 0)
                    draw.line(points, fill=color, width=stroke_width)
            elif tag == "polygon":
                points = self._svg_points(elem.attrib.get("points"))
                if len(points) >= 3:
                    fill = self._svg_paint(elem, "fill", None)
                    stroke = self._svg_paint(elem, "stroke", None)
                    stroke_width = max(1, self._safe_int(elem.attrib.get("stroke-width"), 1))
                    fill_color = self._svg_color(fill, None)
                    outline = self._svg_color(stroke, None)
                    draw.polygon(points, fill=fill_color, outline=outline)
                    # Pillow polygon has no width support; emulate thicker stroke
                    if outline and stroke_width > 1:
                        draw.line(points + [points[0]], fill=outline, width=stroke_width)
            elif tag == "text":
                x = self._safe_int(elem.attrib.get("x"), 0)
                y = self._safe_int(elem.attrib.get("y"), 0)
                fill = self._svg_paint(elem, "fill", "black")
                color = self._svg_color(fill, (0, 0, 0)) or (0, 0, 0)
                draw.text((x, y), elem.text or "", fill=color)

        out = BytesIO()
        image.convert("RGB").save(out, format="PNG")
        payload = out.getvalue()
        return ToolResult(
            success=True,
            result={"message": f"SVG converted to PNG: {file_name}"},
            file_base64=self._b64_from_bytes(payload),
            file_name=file_name,
            file_mime="image/png",
        )

    def _file_yaml_export(self, args: Dict[str, Any]) -> ToolResult:
        file_name = self._sanitize_file_name(args.get("file_name"), "export.yaml", force_ext=".yaml")
        content = args.get("content")
        payload = json.dumps(content, ensure_ascii=False, indent=2).encode("utf-8")
        try:
            import yaml
            payload = yaml.safe_dump(content, allow_unicode=True, sort_keys=False).encode("utf-8")
        except Exception:
            # Fallback to JSON serialization if PyYAML fails.
            pass
        return ToolResult(
            success=True,
            result={"message": f"YAML exported: {file_name}"},
            file_base64=self._b64_from_bytes(payload),
            file_name=file_name,
            file_mime="application/x-yaml",
        )

    def _file_zip_bundle(self, args: Dict[str, Any]) -> ToolResult:
        file_name = self._sanitize_file_name(args.get("file_name"), "bundle.zip", force_ext=".zip")
        entries = args.get("entries") or []
        if not isinstance(entries, list) or not entries:
            return ToolResult(success=False, result=None, error="entries must be non-empty array")

        out = BytesIO()
        with ZipFile(out, mode="w", compression=ZIP_DEFLATED) as zf:
            for idx, entry in enumerate(entries, start=1):
                if not isinstance(entry, dict):
                    return ToolResult(success=False, result=None, error=f"entry[{idx-1}] must be object")
                ename = self._sanitize_file_name(entry.get("file_name"), f"file_{idx}.bin")
                if entry.get("file_base64"):
                    zf.writestr(ename, self._bytes_from_b64(entry["file_base64"]))
                elif "text" in entry:
                    zf.writestr(ename, str(entry["text"]).encode("utf-8"))
                elif "content" in entry:
                    zf.writestr(ename, json.dumps(entry["content"], ensure_ascii=False, indent=2).encode("utf-8"))
                else:
                    return ToolResult(
                        success=False,
                        result=None,
                        error=f"entry[{idx-1}] requires file_base64|text|content",
                    )

        return ToolResult(
            success=True,
            result={"message": f"ZIP bundle created: {file_name}"},
            file_base64=self._b64_from_bytes(out.getvalue()),
            file_name=file_name,
            file_mime="application/zip",
        )

    def _file_excel_create(self, args: Dict[str, Any]) -> ToolResult:
        import openpyxl

        file_name = self._sanitize_file_name(args.get("file_name"), "report.xlsx", force_ext=".xlsx")
        sheets = args.get("sheets")

        wb = openpyxl.Workbook()
        wb.remove(wb.active)
        created = False

        if isinstance(sheets, list) and sheets:
            for idx, sheet in enumerate(sheets, start=1):
                if not isinstance(sheet, dict):
                    return ToolResult(success=False, result=None, error=f"sheets[{idx-1}] must be object")
                sheet_name = str(sheet.get("name") or f"Sheet{idx}")[:31]
                headers = sheet.get("headers") or []
                rows_raw = sheet.get("rows") or []
                rows = self._normalize_rows(rows_raw, headers=headers if headers else None)
                if rows and not headers and isinstance(rows_raw[0], dict):
                    headers = list(rows_raw[0].keys())
                    rows = self._normalize_rows(rows_raw, headers=headers)
                ws = wb.create_sheet(title=sheet_name)
                self._append_sheet_data(ws, headers, rows)
                created = True
        else:
            sheet_name = str(args.get("sheet_name") or "Sheet1")[:31]
            headers = args.get("headers") or []
            rows_raw = args.get("rows") or []
            rows = self._normalize_rows(rows_raw, headers=headers if headers else None)
            if rows and not headers and isinstance(rows_raw[0], dict):
                headers = list(rows_raw[0].keys())
                rows = self._normalize_rows(rows_raw, headers=headers)
            ws = wb.create_sheet(title=sheet_name)
            self._append_sheet_data(ws, headers, rows)
            created = True

        if not created:
            wb.create_sheet(title="Sheet1")

        out = BytesIO()
        wb.save(out)
        return ToolResult(
            success=True,
            result={"message": f"Excel created: {file_name}"},
            file_base64=self._b64_from_bytes(out.getvalue()),
            file_name=file_name,
            file_mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
        )

    def _file_excel_update(self, args: Dict[str, Any]) -> ToolResult:
        import openpyxl

        src_b64 = args.get("file_base64")
        operations = args.get("operations") or []
        if not src_b64:
            return ToolResult(success=False, result=None, error="file_base64 is required for excel_update")
        if not isinstance(operations, list) or not operations:
            return ToolResult(success=False, result=None, error="operations must be non-empty array")

        file_name = self._sanitize_file_name(args.get("file_name"), "updated.xlsx", force_ext=".xlsx")
        wb = openpyxl.load_workbook(filename=BytesIO(self._bytes_from_b64(src_b64)))

        for op in operations:
            if not isinstance(op, dict):
                return ToolResult(success=False, result=None, error="Each operation must be object")
            op_type = str(op.get("type") or "").strip().lower()

            if op_type == "append_rows":
                sheet = str(op.get("sheet") or wb.sheetnames[0])[:31]
                if sheet not in wb.sheetnames:
                    wb.create_sheet(title=sheet)
                ws = wb[sheet]
                rows_raw = op.get("rows") or []
                header_row = [c.value for c in ws[1]] if ws.max_row >= 1 else []
                rows = self._normalize_rows(rows_raw, headers=header_row if header_row else None)
                if rows and not header_row and isinstance(rows_raw[0], dict):
                    header_row = list(rows_raw[0].keys())
                    ws.append(header_row)
                    rows = self._normalize_rows(rows_raw, headers=header_row)
                for row in rows:
                    ws.append(row)
            elif op_type == "set_cell":
                sheet = str(op.get("sheet") or wb.sheetnames[0])[:31]
                cell = op.get("cell")
                if not cell:
                    return ToolResult(success=False, result=None, error="set_cell operation requires 'cell'")
                if sheet not in wb.sheetnames:
                    wb.create_sheet(title=sheet)
                wb[sheet][str(cell)] = op.get("value", "")
            elif op_type == "replace_sheet":
                sheet = str(op.get("sheet") or wb.sheetnames[0])[:31]
                if sheet in wb.sheetnames:
                    wb.remove(wb[sheet])
                ws = wb.create_sheet(title=sheet)
                headers = op.get("headers") or []
                rows_raw = op.get("rows") or []
                rows = self._normalize_rows(rows_raw, headers=headers if headers else None)
                if rows and not headers and isinstance(rows_raw[0], dict):
                    headers = list(rows_raw[0].keys())
                    rows = self._normalize_rows(rows_raw, headers=headers)
                self._append_sheet_data(ws, headers, rows)
            elif op_type == "rename_sheet":
                src = str(op.get("from") or "")
                dst = str(op.get("to") or "").strip()
                if not src or not dst:
                    return ToolResult(success=False, result=None, error="rename_sheet requires 'from' and 'to'")
                if src not in wb.sheetnames:
                    return ToolResult(success=False, result=None, error=f"Sheet not found: {src}")
                wb[src].title = dst[:31]
            else:
                return ToolResult(success=False, result=None, error=f"Unsupported excel_update operation: {op_type}")

        out = BytesIO()
        wb.save(out)
        return ToolResult(
            success=True,
            result={"message": f"Excel updated: {file_name}"},
            file_base64=self._b64_from_bytes(out.getvalue()),
            file_name=file_name,
            file_mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
        )

    def _file_docx_create(self, args: Dict[str, Any]) -> ToolResult:
        from docx import Document

        file_name = self._sanitize_file_name(args.get("file_name"), "document.docx", force_ext=".docx")
        doc = Document()

        title = args.get("title")
        if title:
            doc.add_heading(str(title), level=1)

        for item in args.get("paragraphs") or []:
            doc.add_paragraph(str(item))

        for table in args.get("tables") or []:
            if not isinstance(table, dict):
                continue
            headers = [str(h) for h in (table.get("headers") or [])]
            rows_raw = table.get("rows") or []
            rows = self._normalize_rows(rows_raw, headers=headers if headers else None)
            if rows and not headers and isinstance(rows_raw[0], dict):
                headers = list(rows_raw[0].keys())
                rows = self._normalize_rows(rows_raw, headers=headers)

            total_rows = len(rows) + (1 if headers else 0)
            total_cols = len(headers) if headers else (len(rows[0]) if rows else 1)
            t = doc.add_table(rows=max(total_rows, 1), cols=max(total_cols, 1))

            row_offset = 0
            if headers:
                for idx, value in enumerate(headers):
                    t.cell(0, idx).text = str(value)
                row_offset = 1
            for ridx, row in enumerate(rows):
                for cidx, value in enumerate(row):
                    if cidx < total_cols:
                        t.cell(ridx + row_offset, cidx).text = str(value)

        out = BytesIO()
        doc.save(out)
        return ToolResult(
            success=True,
            result={"message": f"DOCX created: {file_name}"},
            file_base64=self._b64_from_bytes(out.getvalue()),
            file_name=file_name,
            file_mime="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
        )

    def _file_docx_update(self, args: Dict[str, Any]) -> ToolResult:
        from docx import Document

        src_b64 = args.get("file_base64")
        operations = args.get("operations") or []
        if not src_b64:
            return ToolResult(success=False, result=None, error="file_base64 is required for docx_update")
        if not isinstance(operations, list) or not operations:
            return ToolResult(success=False, result=None, error="operations must be non-empty array")

        file_name = self._sanitize_file_name(args.get("file_name"), "updated.docx", force_ext=".docx")
        doc = Document(BytesIO(self._bytes_from_b64(src_b64)))

        for op in operations:
            if not isinstance(op, dict):
                return ToolResult(success=False, result=None, error="Each operation must be object")
            op_type = str(op.get("type") or "").strip().lower()

            if op_type == "append_paragraph":
                doc.add_paragraph(str(op.get("text") or ""))
            elif op_type == "append_heading":
                level = int(op.get("level") or 1)
                level = max(1, min(level, 9))
                doc.add_heading(str(op.get("text") or ""), level=level)
            elif op_type == "replace_text":
                old = str(op.get("old") or "")
                new = str(op.get("new") or "")
                if not old:
                    return ToolResult(success=False, result=None, error="replace_text requires old")
                for p in doc.paragraphs:
                    if old in p.text:
                        p.text = p.text.replace(old, new)
                for table in doc.tables:
                    for row in table.rows:
                        for cell in row.cells:
                            if old in cell.text:
                                cell.text = cell.text.replace(old, new)
            elif op_type == "append_table":
                headers = [str(h) for h in (op.get("headers") or [])]
                rows_raw = op.get("rows") or []
                rows = self._normalize_rows(rows_raw, headers=headers if headers else None)
                if rows and not headers and isinstance(rows_raw[0], dict):
                    headers = list(rows_raw[0].keys())
                    rows = self._normalize_rows(rows_raw, headers=headers)

                total_rows = len(rows) + (1 if headers else 0)
                total_cols = len(headers) if headers else (len(rows[0]) if rows else 1)
                t = doc.add_table(rows=max(total_rows, 1), cols=max(total_cols, 1))

                row_offset = 0
                if headers:
                    for idx, value in enumerate(headers):
                        t.cell(0, idx).text = str(value)
                    row_offset = 1
                for ridx, row in enumerate(rows):
                    for cidx, value in enumerate(row):
                        if cidx < total_cols:
                            t.cell(ridx + row_offset, cidx).text = str(value)
            else:
                return ToolResult(success=False, result=None, error=f"Unsupported docx_update operation: {op_type}")

        out = BytesIO()
        doc.save(out)
        return ToolResult(
            success=True,
            result={"message": f"DOCX updated: {file_name}"},
            file_base64=self._b64_from_bytes(out.getvalue()),
            file_name=file_name,
            file_mime="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
        )

    def _file_pptx_create(self, args: Dict[str, Any]) -> ToolResult:
        from pptx import Presentation

        file_name = self._sanitize_file_name(args.get("file_name"), "slides.pptx", force_ext=".pptx")
        prs = Presentation()

        title = str(args.get("title") or "").strip()
        subtitle = str(args.get("subtitle") or "").strip()
        if title or subtitle:
            slide = prs.slides.add_slide(prs.slide_layouts[0])
            if title and slide.shapes.title:
                slide.shapes.title.text = title
            if subtitle and len(slide.placeholders) > 1:
                slide.placeholders[1].text = subtitle

        for entry in args.get("slides") or []:
            if not isinstance(entry, dict):
                continue
            slide = prs.slides.add_slide(prs.slide_layouts[1])
            if slide.shapes.title:
                slide.shapes.title.text = str(entry.get("title") or "")
            body = None
            if len(slide.placeholders) > 1:
                body = slide.placeholders[1].text_frame
            lines = entry.get("bullets")
            if lines is None:
                lines = entry.get("lines")
            if lines is None:
                lines = [entry.get("text")] if entry.get("text") is not None else []
            if body is not None:
                body.clear()
                first = True
                for line in lines:
                    if first:
                        body.text = str(line)
                        first = False
                    else:
                        p = body.add_paragraph()
                        p.text = str(line)

        out = BytesIO()
        prs.save(out)
        return ToolResult(
            success=True,
            result={"message": f"PPTX created: {file_name}"},
            file_base64=self._b64_from_bytes(out.getvalue()),
            file_name=file_name,
            file_mime="application/vnd.openxmlformats-officedocument.presentationml.presentation",
        )

    def _file_pptx_update(self, args: Dict[str, Any]) -> ToolResult:
        from pptx import Presentation
        from pptx.util import Inches

        src_b64 = args.get("file_base64")
        operations = args.get("operations") or []
        if not src_b64:
            return ToolResult(success=False, result=None, error="file_base64 is required for pptx_update")
        if not isinstance(operations, list) or not operations:
            return ToolResult(success=False, result=None, error="operations must be non-empty array")

        file_name = self._sanitize_file_name(args.get("file_name"), "updated.pptx", force_ext=".pptx")
        prs = Presentation(BytesIO(self._bytes_from_b64(src_b64)))

        for op in operations:
            if not isinstance(op, dict):
                return ToolResult(success=False, result=None, error="Each operation must be object")
            op_type = str(op.get("type") or "").strip().lower()

            if op_type == "append_slide":
                layout_idx = int(op.get("layout") or 1)
                if layout_idx < 0 or layout_idx >= len(prs.slide_layouts):
                    layout_idx = 1
                slide = prs.slides.add_slide(prs.slide_layouts[layout_idx])
                if slide.shapes.title:
                    slide.shapes.title.text = str(op.get("title") or "")
                lines = op.get("bullets")
                if lines is None:
                    lines = op.get("lines")
                if lines is None:
                    lines = [op.get("text")] if op.get("text") is not None else []
                if len(slide.placeholders) > 1:
                    body = slide.placeholders[1].text_frame
                    body.clear()
                    first = True
                    for line in lines:
                        if first:
                            body.text = str(line)
                            first = False
                        else:
                            p = body.add_paragraph()
                            p.text = str(line)
            elif op_type == "add_table":
                slide_index = int(op.get("slide_index") or len(prs.slides))
                if slide_index < 0:
                    slide_index = 0
                while len(prs.slides) <= slide_index:
                    prs.slides.add_slide(prs.slide_layouts[1])
                slide = prs.slides[slide_index]
                headers = [str(h) for h in (op.get("headers") or [])]
                rows_raw = op.get("rows") or []
                rows = self._normalize_rows(rows_raw, headers=headers if headers else None)
                if rows and not headers and isinstance(rows_raw[0], dict):
                    headers = list(rows_raw[0].keys())
                    rows = self._normalize_rows(rows_raw, headers=headers)
                row_count = len(rows) + (1 if headers else 0)
                col_count = len(headers) if headers else (len(rows[0]) if rows else 1)
                left = Inches(float(op.get("left_inches") or 1.0))
                top = Inches(float(op.get("top_inches") or 1.5))
                width = Inches(float(op.get("width_inches") or 8.0))
                height = Inches(float(op.get("height_inches") or 3.0))
                table = slide.shapes.add_table(max(1, row_count), max(1, col_count), left, top, width, height).table
                offset = 0
                if headers:
                    for idx, value in enumerate(headers):
                        table.cell(0, idx).text = str(value)
                    offset = 1
                for r_idx, row in enumerate(rows):
                    for c_idx, value in enumerate(row):
                        if c_idx < col_count:
                            table.cell(r_idx + offset, c_idx).text = str(value)
            elif op_type == "replace_text":
                old = str(op.get("old") or "")
                new = str(op.get("new") or "")
                if not old:
                    return ToolResult(success=False, result=None, error="replace_text requires old")
                for slide in prs.slides:
                    for shape in slide.shapes:
                        if not hasattr(shape, "text"):
                            continue
                        text = shape.text or ""
                        if old in text:
                            shape.text = text.replace(old, new)
            elif op_type == "replace_text_preserve_layout":
                old = str(op.get("old") or "")
                new = str(op.get("new") or "")
                if not old:
                    return ToolResult(success=False, result=None, error="replace_text_preserve_layout requires old")
                for slide in prs.slides:
                    for shape in slide.shapes:
                        if hasattr(shape, "text_frame") and shape.text_frame:
                            for paragraph in shape.text_frame.paragraphs:
                                for run in paragraph.runs:
                                    if old in run.text:
                                        run.text = run.text.replace(old, new)
                        if getattr(shape, "has_table", False):
                            for row in shape.table.rows:
                                for cell in row.cells:
                                    for paragraph in cell.text_frame.paragraphs:
                                        for run in paragraph.runs:
                                            if old in run.text:
                                                run.text = run.text.replace(old, new)
            else:
                return ToolResult(success=False, result=None, error=f"Unsupported pptx_update operation: {op_type}")

        out = BytesIO()
        prs.save(out)
        return ToolResult(
            success=True,
            result={"message": f"PPTX updated: {file_name}"},
            file_base64=self._b64_from_bytes(out.getvalue()),
            file_name=file_name,
            file_mime="application/vnd.openxmlformats-officedocument.presentationml.presentation",
        )

    def _file_pdf_merge(self, args: Dict[str, Any]) -> ToolResult:
        from pypdf import PdfReader, PdfWriter

        file_name = self._sanitize_file_name(args.get("file_name"), "merged.pdf", force_ext=".pdf")
        files = args.get("files") or []
        if not isinstance(files, list) or not files:
            return ToolResult(success=False, result=None, error="files must be non-empty array for pdf_merge")

        writer = PdfWriter()
        page_count = 0
        for item in files:
            if not isinstance(item, dict) or not item.get("file_base64"):
                return ToolResult(success=False, result=None, error="Each file entry must include file_base64")
            reader = PdfReader(BytesIO(self._bytes_from_b64(item["file_base64"])))
            for page in reader.pages:
                writer.add_page(page)
                page_count += 1

        out = BytesIO()
        writer.write(out)
        return ToolResult(
            success=True,
            result={"message": f"PDF merged: {file_name} ({page_count} pages)"},
            file_base64=self._b64_from_bytes(out.getvalue()),
            file_name=file_name,
            file_mime="application/pdf",
        )

    @staticmethod
    def _parse_split_pages(pages: Any) -> Optional[List[int]]:
        if not isinstance(pages, list) or not pages:
            return None
        parsed: List[int] = []
        for p in pages:
            idx = int(p)
            if idx < 1:
                return None
            parsed.append(idx)
        return sorted(set(parsed))

    def _file_pdf_split(self, args: Dict[str, Any]) -> ToolResult:
        from pypdf import PdfReader, PdfWriter

        src_b64 = args.get("file_base64")
        if not src_b64:
            return ToolResult(success=False, result=None, error="file_base64 is required for pdf_split")
        file_name = self._sanitize_file_name(args.get("file_name"), "split.zip", force_ext=".zip")

        reader = PdfReader(BytesIO(self._bytes_from_b64(src_b64)))
        total = len(reader.pages)
        if total == 0:
            return ToolResult(success=False, result=None, error="Input PDF has no pages")

        groups = args.get("groups")
        split_groups = []
        if groups:
            if not isinstance(groups, list):
                return ToolResult(success=False, result=None, error="groups must be array")
            for idx, grp in enumerate(groups, start=1):
                if not isinstance(grp, dict):
                    return ToolResult(success=False, result=None, error="Each group must be object")
                gname = self._sanitize_file_name(grp.get("file_name"), f"part_{idx}.pdf", force_ext=".pdf")
                pages = self._parse_split_pages(grp.get("pages"))
                if not pages:
                    return ToolResult(success=False, result=None, error=f"Invalid pages in group {idx}")
                split_groups.append((gname, pages))
        else:
            split_groups = [(f"page_{i+1}.pdf", [i + 1]) for i in range(total)]

        out = BytesIO()
        with ZipFile(out, mode="w", compression=ZIP_DEFLATED) as zf:
            for gname, pages in split_groups:
                writer = PdfWriter()
                for p in pages:
                    if p > total:
                        return ToolResult(success=False, result=None, error=f"Page out of range: {p} > {total}")
                    writer.add_page(reader.pages[p - 1])
                part = BytesIO()
                writer.write(part)
                zf.writestr(gname, part.getvalue())

        return ToolResult(
            success=True,
            result={"message": f"PDF split into {len(split_groups)} file(s): {file_name}"},
            file_base64=self._b64_from_bytes(out.getvalue()),
            file_name=file_name,
            file_mime="application/zip",
        )

    def _file_pdf_fill(self, args: Dict[str, Any]) -> ToolResult:
        from pypdf import PdfReader, PdfWriter

        src_b64 = args.get("file_base64")
        fields = args.get("fields") or {}
        if not src_b64:
            return ToolResult(success=False, result=None, error="file_base64 is required for pdf_fill")
        if not isinstance(fields, dict) or not fields:
            return ToolResult(success=False, result=None, error="fields must be a non-empty object")

        file_name = self._sanitize_file_name(args.get("file_name"), "filled.pdf", force_ext=".pdf")
        reader = PdfReader(BytesIO(self._bytes_from_b64(src_b64)))
        writer = PdfWriter()
        writer.append(reader)

        filled = True
        try:
            for page in writer.pages:
                writer.update_page_form_field_values(page, fields)
            if hasattr(writer, "set_need_appearances_writer"):
                writer.set_need_appearances_writer(True)
        except Exception:
            filled = False

        out = BytesIO()
        writer.write(out)
        msg = f"PDF form filled: {file_name}" if filled else f"PDF has no fillable form fields, returned unchanged: {file_name}"
        return ToolResult(
            success=True,
            result={"message": msg},
            file_base64=self._b64_from_bytes(out.getvalue()),
            file_name=file_name,
            file_mime="application/pdf",
        )

    def _file_pdf_update(self, args: Dict[str, Any]) -> ToolResult:
        from pypdf import PdfReader, PdfWriter

        src_b64 = args.get("file_base64")
        operations = args.get("operations") or []
        if not src_b64:
            return ToolResult(success=False, result=None, error="file_base64 is required for pdf_update")
        if not isinstance(operations, list) or not operations:
            return ToolResult(success=False, result=None, error="operations must be non-empty array")

        file_name = self._sanitize_file_name(args.get("file_name"), "updated.pdf", force_ext=".pdf")
        reader = PdfReader(BytesIO(self._bytes_from_b64(src_b64)))
        pages = [reader.pages[i] for i in range(len(reader.pages))]
        total = len(pages)
        if total == 0:
            return ToolResult(success=False, result=None, error="Input PDF has no pages")

        def parse_pages_list(raw: Any, allow_empty: bool = False) -> Optional[List[int]]:
            if raw is None:
                return [] if allow_empty else None
            if not isinstance(raw, list) or (not raw and not allow_empty):
                return None
            out: List[int] = []
            for val in raw:
                try:
                    idx = int(val)
                except Exception:
                    return None
                if idx < 1 or idx > len(pages):
                    return None
                out.append(idx)
            return out

        for op in operations:
            if not isinstance(op, dict):
                return ToolResult(success=False, result=None, error="Each operation must be object")
            op_type = str(op.get("type") or "").strip().lower()

            if op_type == "rotate_pages":
                angle = int(op.get("angle") or 90)
                if angle not in {90, 180, 270}:
                    return ToolResult(success=False, result=None, error="rotate_pages angle must be 90|180|270")
                target = parse_pages_list(op.get("pages"), allow_empty=True)
                if target is None:
                    return ToolResult(success=False, result=None, error="Invalid pages for rotate_pages")
                targets = target or list(range(1, len(pages) + 1))
                for p in targets:
                    page = pages[p - 1]
                    try:
                        pages[p - 1] = page.rotate(angle)
                    except Exception:
                        if hasattr(page, "rotate_clockwise"):
                            page.rotate_clockwise(angle)
                            pages[p - 1] = page
                        else:
                            return ToolResult(success=False, result=None, error="PDF rotation not supported by library")
            elif op_type == "remove_pages":
                target = parse_pages_list(op.get("pages"))
                if not target:
                    return ToolResult(success=False, result=None, error="remove_pages requires pages")
                drop = set(target)
                pages = [p for idx, p in enumerate(pages, start=1) if idx not in drop]
                if not pages:
                    return ToolResult(success=False, result=None, error="remove_pages removed all pages")
            elif op_type in {"reorder_pages", "extract_pages"}:
                target = parse_pages_list(op.get("pages"))
                if not target:
                    return ToolResult(success=False, result=None, error=f"{op_type} requires pages")
                pages = [pages[i - 1] for i in target]
            elif op_type == "set_metadata":
                # Applied later on writer to avoid page object recreation.
                continue
            else:
                return ToolResult(success=False, result=None, error=f"Unsupported pdf_update operation: {op_type}")

        writer = PdfWriter()
        for page in pages:
            writer.add_page(page)
        for op in operations:
            if isinstance(op, dict) and str(op.get("type") or "").strip().lower() == "set_metadata":
                meta = op.get("metadata")
                if isinstance(meta, dict) and meta:
                    normalized = {k if str(k).startswith("/") else f"/{k}": str(v) for k, v in meta.items()}
                    writer.add_metadata(normalized)

        out = BytesIO()
        writer.write(out)
        return ToolResult(
            success=True,
            result={"message": f"PDF updated: {file_name} ({len(pages)} pages)"},
            file_base64=self._b64_from_bytes(out.getvalue()),
            file_name=file_name,
            file_mime="application/pdf",
        )

    def _file_djvu_to_pdf(self, args: Dict[str, Any]) -> ToolResult:
        src_b64 = args.get("file_base64")
        if not src_b64:
            return ToolResult(success=False, result=None, error="file_base64 is required for djvu_to_pdf")
        file_name = self._sanitize_file_name(args.get("file_name"), "converted.pdf", force_ext=".pdf")

        timeout_sec = max(5, min(int(args.get("timeout_sec") or 60), 300))
        with tempfile.TemporaryDirectory(prefix="djvu2pdf_") as tmpdir:
            src = os.path.join(tmpdir, "input.djvu")
            out_pdf = os.path.join(tmpdir, "output.pdf")
            with open(src, "wb") as f:
                f.write(self._bytes_from_b64(src_b64))

            try:
                proc = subprocess.run(
                    ["ddjvu", "-format=pdf", src, out_pdf],
                    capture_output=True,
                    text=True,
                    timeout=timeout_sec,
                    check=False,
                )
            except FileNotFoundError:
                return ToolResult(success=False, result=None, error="ddjvu not found in runtime image")
            except subprocess.TimeoutExpired:
                return ToolResult(success=False, result=None, error=f"DJVU conversion timed out ({timeout_sec}s)")

            if proc.returncode != 0 or not os.path.exists(out_pdf):
                stderr = (proc.stderr or "").strip()
                return ToolResult(success=False, result=None, error=f"ddjvu failed: {stderr or 'unknown error'}")

            data = open(out_pdf, "rb").read()
            if not data:
                return ToolResult(success=False, result=None, error="ddjvu produced empty PDF")

        return ToolResult(
            success=True,
            result={"message": f"DJVU converted to PDF: {file_name}"},
            file_base64=self._b64_from_bytes(data),
            file_name=file_name,
            file_mime="application/pdf",
        )

    def _file_djvu_extract_text(self, args: Dict[str, Any]) -> ToolResult:
        src_b64 = args.get("file_base64")
        if not src_b64:
            return ToolResult(success=False, result=None, error="file_base64 is required for djvu_extract_text")
        file_name = self._sanitize_file_name(args.get("file_name"), "extracted.txt", force_ext=".txt")
        timeout_sec = max(5, min(int(args.get("timeout_sec") or 60), 300))

        with tempfile.TemporaryDirectory(prefix="djvutxt_") as tmpdir:
            src = os.path.join(tmpdir, "input.djvu")
            with open(src, "wb") as f:
                f.write(self._bytes_from_b64(src_b64))
            try:
                proc = subprocess.run(
                    ["djvutxt", src],
                    capture_output=True,
                    text=True,
                    timeout=timeout_sec,
                    check=False,
                )
            except FileNotFoundError:
                return ToolResult(success=False, result=None, error="djvutxt not found in runtime image")
            except subprocess.TimeoutExpired:
                return ToolResult(success=False, result=None, error=f"DJVU text extraction timed out ({timeout_sec}s)")

            if proc.returncode != 0:
                stderr = (proc.stderr or "").strip()
                return ToolResult(success=False, result=None, error=f"djvutxt failed: {stderr or 'unknown error'}")
            text = proc.stdout or ""
            msg = f"DJVU text extracted: {file_name}"
            if not text.strip():
                msg = f"DJVU has no extractable text layer, returned empty text file: {file_name}"

        payload = text.encode("utf-8")
        return ToolResult(
            success=True,
            result={"message": msg},
            file_base64=self._b64_from_bytes(payload),
            file_name=file_name,
            file_mime="text/plain",
        )
    
    async def _memory_search(self, args: Dict, agent_id: str = None, chat_id: str = None, user_id: str = None) -> ToolResult:
        """Search in Qdrant vector memory using Router's memory_retrieval - PRIORITY 1"""
        query = args.get("query")
        
        try:
            # Use Router's memory_retrieval pipeline directly (has Qdrant connection)
            from memory_retrieval import memory_retrieval
            
            if memory_retrieval and memory_retrieval.qdrant_client:
                results = await memory_retrieval.search_memories(
                    query=query,
                    agent_id=agent_id or "helion",
                    chat_id=chat_id,
                    user_id=user_id,
                    limit=5
                )
                
                if results:
                    formatted = []
                    for r in results:
                        text = r.get("text", "")
                        score = r.get("score", 0)
                        mem_type = r.get("type", "memory")
                        if text:
                            formatted.append(f"• [{mem_type}] {text[:200]}... (релевантність: {score:.2f})")
                    if formatted:
                        return ToolResult(success=True, result=f"🧠 Знайдено в пам'яті:\n" + "\n".join(formatted))
                
                return ToolResult(success=True, result="🧠 В моїй пам'яті немає інформації про це.")
            else:
                return ToolResult(success=True, result="🧠 Пам'ять недоступна, спробую web_search.")
        except Exception as e:
            logger.warning(f"Memory search error: {e}")
            return ToolResult(success=True, result="🧠 Не вдалося перевірити пам'ять. Спробую інші джерела.")
    
    async def _web_search(self, args: Dict) -> ToolResult:
        """Execute web search - PRIORITY 2 (use after memory_search)"""
        query = args.get("query")
        max_results = args.get("max_results", 5)
        if not query:
            return ToolResult(success=False, result=None, error="query is required")
        
        try:
            resp = await self.http_client.post(
                f"{self.swapper_url}/web/search",
                json={"query": query, "max_results": max_results}
            )
            if resp.status_code == 200:
                data = resp.json()
                results = data.get("results", []) or []
                query_terms = {t for t in str(query).lower().replace("/", " ").replace("-", " ").split() if len(t) > 2}

                trusted_domains = {
                    "wikipedia.org", "wikidata.org", "europa.eu", "fao.org", "who.int",
                    "worldbank.org", "oecd.org", "un.org", "gov.ua", "rada.gov.ua",
                    "kmu.gov.ua", "minagro.gov.ua", "agroportal.ua", "latifundist.com",
                }
                low_signal_domains = {
                    "pinterest.com", "tiktok.com", "instagram.com", "facebook.com",
                    "youtube.com", "yandex.", "vk.com",
                }

                def _extract_domain(url: str) -> str:
                    if not url:
                        return ""
                    u = url.lower().strip()
                    u = u.replace("https://", "").replace("http://", "")
                    u = u.split("/")[0]
                    if u.startswith("www."):
                        u = u[4:]
                    return u

                def _overlap_score(title: str, snippet: str, url: str) -> int:
                    text = " ".join([title or "", snippet or "", url or ""]).lower()
                    score = 0
                    for t in query_terms:
                        if t in text:
                            score += 2
                    return score

                ranked: List[Any] = []
                for r in results:
                    title = str(r.get("title", "") or "")
                    snippet = str(r.get("snippet", "") or "")
                    url = str(r.get("url", "") or "")
                    domain = _extract_domain(url)
                    score = _overlap_score(title, snippet, url)

                    if any(x in domain for x in low_signal_domains):
                        score -= 2
                    if any(domain == d or domain.endswith("." + d) for d in trusted_domains):
                        score += 2
                    if not snippet:
                        score -= 1
                    if len(title.strip()) < 5:
                        score -= 1

                    ranked.append((score, r))

                ranked.sort(key=lambda x: x[0], reverse=True)
                selected = [item for _, item in ranked[:max_results]]

                logger.info(f"🔎 web_search rerank: raw={len(results)} ranked={len(selected)} query='{query[:120]}'")

                formatted = []
                for r in selected:
                    formatted.append(
                        f"- {r.get('title', 'No title')}\n  {r.get('snippet', '')}\n  URL: {r.get('url', '')}"
                    )

                return ToolResult(success=True, result="\n".join(formatted) if formatted else "Нічого не знайдено")
            else:
                return ToolResult(success=False, result=None, error=f"Search failed: {resp.status_code}")
        except Exception as e:
            return ToolResult(success=False, result=None, error=str(e))
    
    async def _web_extract(self, args: Dict) -> ToolResult:
        """Extract content from URL"""
        url = args.get("url")
        
        try:
            resp = await self.http_client.post(
                f"{self.swapper_url}/web/extract",
                json={"url": url}
            )
            if resp.status_code == 200:
                data = resp.json()
                content = data.get("content", "")
                # Truncate if too long
                if len(content) > 4000:
                    content = content[:4000] + "\n... (текст обрізано)"
                return ToolResult(success=True, result=content)
            else:
                return ToolResult(success=False, result=None, error=f"Extract failed: {resp.status_code}")
        except Exception as e:
            return ToolResult(success=False, result=None, error=str(e))
    
    async def _unload_ollama_models(self):
        """Unload all Ollama models to free VRAM for heavy operations like FLUX"""
        ollama_url = os.getenv("OLLAMA_BASE_URL", "http://172.18.0.1:11434")
        models_to_unload = ["qwen3:8b", "qwen3-vl:8b"]
        
        for model in models_to_unload:
            try:
                await self.http_client.post(
                    f"{ollama_url}/api/generate",
                    json={"model": model, "keep_alive": 0},
                    timeout=5.0
                )
                logger.info(f"🧹 Unloaded Ollama model: {model}")
            except Exception as e:
                logger.debug(f"Could not unload {model}: {e}")
        
        # Give GPU time to release memory
        import asyncio
        await asyncio.sleep(1)
    
    async def _unload_flux(self):
        """Unload FLUX model after image generation to free VRAM"""
        try:
            # Try to unload flux-klein-4b model
            await self.http_client.post(
                f"{self.swapper_url}/image/models/flux-klein-4b/unload",
                timeout=10.0
            )
            logger.info("🧹 Unloaded FLUX model from Swapper")
        except Exception as e:
            logger.debug(f"Could not unload FLUX: {e}")
    
    async def _image_generate(self, args: Dict) -> ToolResult:
        """Backward-compatible image generation with fallback chain."""
        if not args.get("prompt"):
            return ToolResult(success=False, result=None, error="prompt is required")

        prepared = dict(args)
        prepared.setdefault("negative_prompt", "blurry, low quality, watermark")
        prepared.setdefault("steps", 28)
        prepared.setdefault("timeout_s", 180)

        providers = [
            ("comfy", self._comfy_generate_image),
            ("swapper", self._swapper_generate_image),
            ("image_gen_service", self._image_gen_service_generate),
        ]
        errors: List[str] = []
        for name, handler in providers:
            try:
                result = await handler(dict(prepared))
            except Exception as e:
                msg = f"{name}: {str(e)[:200]}"
                logger.warning("image_generate provider exception: %s", msg)
                errors.append(msg)
                continue
            if result.success:
                return result
            errors.append(f"{name}: {result.error or 'unknown error'}")

        return ToolResult(
            success=False,
            result=None,
            error="All image providers failed: " + " | ".join(errors),
        )

    async def _swapper_generate_image(self, args: Dict) -> ToolResult:
        """Generate image via swapper-service (local fallback)."""
        prompt = args.get("prompt")
        if not prompt:
            return ToolResult(success=False, result=None, error="prompt is required")
        payload = {
            "model": args.get("model", "flux-klein-4b"),
            "prompt": prompt,
            "negative_prompt": args.get("negative_prompt", ""),
            "num_inference_steps": int(args.get("steps", args.get("num_inference_steps", 28))),
            "guidance_scale": float(args.get("guidance_scale", 4.0)),
            "width": int(args.get("width", 1024)),
            "height": int(args.get("height", 1024)),
        }
        timeout_s = max(30, int(args.get("timeout_s", 180)))
        try:
            resp = await self.http_client.post(
                f"{self.swapper_url}/image/generate",
                json=payload,
                timeout=timeout_s,
            )
        except Exception as e:
            return ToolResult(success=False, result=None, error=f"Swapper request failed: {str(e)[:200]}")
        if resp.status_code >= 400:
            detail = (resp.text or "").strip()[:220]
            return ToolResult(success=False, result=None, error=f"Swapper error {resp.status_code}: {detail}")

        data = resp.json()
        image_base64 = data.get("image_base64")
        if image_base64:
            return ToolResult(
                success=True,
                result="✅ Зображення згенеровано через swapper-service",
                image_base64=image_base64,
            )
        if data.get("success"):
            return ToolResult(success=True, result=json.dumps(data, ensure_ascii=False))
        return ToolResult(success=False, result=None, error="Swapper returned no image payload")

    async def _image_gen_service_generate(self, args: Dict) -> ToolResult:
        """Generate image via image-gen-service as final fallback."""
        prompt = args.get("prompt")
        if not prompt:
            return ToolResult(success=False, result=None, error="prompt is required")
        payload = {
            "prompt": prompt,
            "negative_prompt": args.get("negative_prompt"),
            "width": int(args.get("width", 1024)),
            "height": int(args.get("height", 1024)),
            "num_inference_steps": int(args.get("steps", args.get("num_inference_steps", 25))),
            "guidance_scale": float(args.get("guidance_scale", 3.5)),
        }
        seed = args.get("seed")
        if seed is not None:
            payload["seed"] = int(seed)
        timeout_s = max(30, int(args.get("timeout_s", 240)))
        try:
            resp = await self.http_client.post(
                f"{self.image_gen_service_url}/generate",
                json=payload,
                timeout=timeout_s,
            )
        except Exception as e:
            return ToolResult(success=False, result=None, error=f"Image-gen request failed: {str(e)[:200]}")
        if resp.status_code >= 400:
            detail = (resp.text or "").strip()[:220]
            return ToolResult(success=False, result=None, error=f"Image-gen error {resp.status_code}: {detail}")

        data = resp.json()
        image_base64 = data.get("image_base64")
        if image_base64:
            return ToolResult(
                success=True,
                result="✅ Зображення згенеровано через image-gen-service",
                image_base64=image_base64,
            )
        return ToolResult(success=False, result=None, error="Image-gen service returned no image payload")

    async def _poll_comfy_job(self, job_id: str, timeout_s: int = 180) -> Dict[str, Any]:
        """Poll Comfy Agent job status until terminal state or timeout."""
        loop = asyncio.get_running_loop()
        deadline = loop.time() + max(10, timeout_s)
        delay = 1.0
        last: Dict[str, Any] = {}

        while loop.time() < deadline:
            resp = await self.http_client.get(f"{self.comfy_agent_url}/status/{job_id}", timeout=30.0)
            if resp.status_code != 200:
                raise RuntimeError(f"Comfy status failed: {resp.status_code}")

            data = resp.json()
            last = data
            status = (data.get("status") or "").lower()

            if status in {"succeeded", "finished"}:
                return data
            if status in {"failed", "canceled", "cancelled", "expired"}:
                return data

            await asyncio.sleep(delay)
            delay = min(delay * 1.5, 5.0)

        raise TimeoutError(f"Comfy job timeout after {timeout_s}s (job_id={job_id})")

    async def _comfy_generate_image(self, args: Dict) -> ToolResult:
        """Generate image via Comfy Agent on NODE3 and return URL when ready."""
        prompt = args.get("prompt")
        if not prompt:
            return ToolResult(success=False, result=None, error="prompt is required")

        payload = {
            "prompt": prompt,
            "negative_prompt": args.get("negative_prompt", "blurry, low quality, watermark"),
            "width": int(args.get("width", 512)),
            "height": int(args.get("height", 512)),
            "steps": int(args.get("steps", 28)),
        }
        if args.get("seed") is not None:
            payload["seed"] = int(args["seed"])

        timeout_s = int(args.get("timeout_s", 180))
        idem_key = args.get("idempotency_key") or f"router-{uuid.uuid4().hex}"

        try:
            resp = await self.http_client.post(
                f"{self.comfy_agent_url}/generate/image",
                json=payload,
                headers={"Idempotency-Key": idem_key},
                timeout=30.0,
            )
            if resp.status_code != 200:
                return ToolResult(success=False, result=None, error=f"Comfy image request failed: {resp.status_code}")

            created = resp.json()
            job_id = created.get("job_id")
            if not job_id:
                return ToolResult(success=False, result=None, error="Comfy image request did not return job_id")

            final = await self._poll_comfy_job(job_id, timeout_s=timeout_s)
            status = (final.get("status") or "").lower()
            if status in {"succeeded", "finished"}:
                result_url = final.get("result_url")
                if result_url:
                    return ToolResult(success=True, result=f"✅ Зображення згенеровано: {result_url}")
                return ToolResult(success=True, result=f"✅ Зображення згенеровано. job_id={job_id}")

            return ToolResult(success=False, result=None, error=final.get("error") or f"Comfy image failed (status={status})")
        except Exception as e:
            return ToolResult(success=False, result=None, error=str(e))

    async def _comfy_generate_video(self, args: Dict) -> ToolResult:
        """Generate video via Comfy Agent on NODE3 and return URL when ready."""
        prompt = args.get("prompt")
        if not prompt:
            return ToolResult(success=False, result=None, error="prompt is required")

        payload = {
            "prompt": prompt,
            "seconds": int(args.get("seconds", 4)),
            "fps": int(args.get("fps", 24)),
            "steps": int(args.get("steps", 30)),
        }
        if args.get("seed") is not None:
            payload["seed"] = int(args["seed"])

        timeout_s = int(args.get("timeout_s", 300))
        idem_key = args.get("idempotency_key") or f"router-{uuid.uuid4().hex}"

        try:
            resp = await self.http_client.post(
                f"{self.comfy_agent_url}/generate/video",
                json=payload,
                headers={"Idempotency-Key": idem_key},
                timeout=30.0,
            )
            if resp.status_code != 200:
                return ToolResult(success=False, result=None, error=f"Comfy video request failed: {resp.status_code}")

            created = resp.json()
            job_id = created.get("job_id")
            if not job_id:
                return ToolResult(success=False, result=None, error="Comfy video request did not return job_id")

            final = await self._poll_comfy_job(job_id, timeout_s=timeout_s)
            status = (final.get("status") or "").lower()
            if status in {"succeeded", "finished"}:
                result_url = final.get("result_url")
                if result_url:
                    return ToolResult(success=True, result=f"✅ Відео згенеровано: {result_url}")
                return ToolResult(success=True, result=f"✅ Відео згенеровано. job_id={job_id}")

            return ToolResult(success=False, result=None, error=final.get("error") or f"Comfy video failed (status={status})")
        except Exception as e:
            return ToolResult(success=False, result=None, error=str(e))
    
    async def _graph_query(self, args: Dict, agent_id: str = None) -> ToolResult:
        """Query knowledge graph"""
        query = args.get("query")
        entity_type = args.get("entity_type")
        
        # Simple natural language to Cypher conversion
        cypher = f"""
        MATCH (n)
        WHERE toLower(n.name) CONTAINS toLower('{query}')
           OR toLower(toString(n)) CONTAINS toLower('{query}')
        RETURN labels(n)[0] as type, n.name as name, n.node_id as id
        LIMIT 10
        """
        
        if entity_type:
            cypher = f"""
            MATCH (n:{entity_type})
            WHERE toLower(n.name) CONTAINS toLower('{query}')
            RETURN n.name as name, n.node_id as id
            LIMIT 10
            """
        
        try:
            # Execute via Router's graph endpoint
            resp = await self.http_client.post(
                "http://localhost:8000/v1/graph/query",
                json={"query": cypher}
            )
            if resp.status_code == 200:
                data = resp.json()
                return ToolResult(success=True, result=json.dumps(data.get("results", []), ensure_ascii=False))
            else:
                return ToolResult(success=False, result=None, error=f"Graph query failed: {resp.status_code}")
        except Exception as e:
            return ToolResult(success=False, result=None, error=str(e))
    
    async def _remember_fact(self, args: Dict, agent_id: str = None, chat_id: str = None, user_id: str = None) -> ToolResult:
        """Store a fact in memory with strict args validation."""
        if not isinstance(args, dict) or not args:
            logger.warning("⚠️ remember_fact blocked: empty args")
            return ToolResult(success=False, result=None, error="invalid_tool_args: remember_fact requires {fact: <non-empty string>}.")

        fact_raw = args.get("fact")
        if fact_raw is None:
            fact_raw = args.get("text")

        if not isinstance(fact_raw, str):
            logger.warning("⚠️ remember_fact blocked: fact/text must be string")
            return ToolResult(success=False, result=None, error="invalid_tool_args: fact/text must be string.")

        fact = fact_raw.strip()
        if not fact:
            logger.warning("⚠️ remember_fact blocked: empty fact/text")
            return ToolResult(success=False, result=None, error="invalid_tool_args: fact/text must be non-empty.")

        if len(fact) > 2000:
            logger.warning("⚠️ remember_fact blocked: fact too long (%s)", len(fact))
            return ToolResult(success=False, result=None, error="invalid_tool_args: fact/text is too long (max 2000 chars).")

        category = str(args.get("category") or "general").strip() or "general"
        runtime_user_id = (str(user_id or "").strip() or str(args.get("user_id") or "").strip() or str(args.get("about") or "").strip())
        if not runtime_user_id:
            logger.warning("⚠️ remember_fact blocked: missing runtime user_id")
            return ToolResult(success=False, result=None, error="invalid_tool_args: missing runtime user_id for memory write.")

        fact_hash = hashlib.sha1(fact.encode("utf-8")).hexdigest()[:12]
        fact_key = f"{category}_{fact_hash}"

        try:
            resp = await self.http_client.post(
                "http://memory-service:8000/facts/upsert",
                json={
                    "user_id": runtime_user_id,
                    "fact_key": fact_key,
                    "fact_value": fact,
                    "agent_id": agent_id,
                    "fact_value_json": {
                        "text": fact,
                        "category": category,
                        "about": runtime_user_id,
                        "agent_id": agent_id,
                        "chat_id": chat_id,
                        "source": "remember_fact_tool",
                    },
                },
            )
            if resp.status_code in [200, 201]:
                return ToolResult(success=True, result=f"✅ Запам'ятовано факт ({category})")
            return ToolResult(success=False, result=None, error=f"memory_store_failed:{resp.status_code}:{resp.text[:160]}")
        except Exception as e:
            return ToolResult(success=False, result=None, error=str(e))
    
    async def _presentation_create(self, args: Dict) -> ToolResult:
        """Create a presentation via Presentation Renderer"""
        title = args.get("title", "Презентація")
        slides = args.get("slides", [])
        brand_id = args.get("brand_id", "energyunion")
        theme_version = args.get("theme_version", "v1.0.0")
        language = args.get("language", "uk")
        
        # Build SlideSpec
        slidespec = {
            "meta": {
                "title": title,
                "brand_id": brand_id,
                "theme_version": theme_version,
                "language": language
            },
            "slides": []
        }
        
        # Add title slide
        slidespec["slides"].append({
            "type": "title",
            "title": title
        })
        
        # Add content slides
        for slide in slides:
            slide_obj = {
                "type": "content",
                "title": slide.get("title", ""),
                "body": slide.get("content", "")
            }
            slidespec["slides"].append(slide_obj)
        
        try:
            renderer_url = os.getenv("PRESENTATION_RENDERER_URL", "http://presentation-renderer:9600")
            resp = await self.http_client.post(
                f"{renderer_url}/present/render",
                json=slidespec,
                timeout=120.0
            )
            if resp.status_code == 200:
                data = resp.json()
                job_id = data.get("job_id")
                artifact_id = data.get("artifact_id")
                return ToolResult(
                    success=True, 
                    result=f"📊 Презентацію створено!\n\n🆔 Job ID: `{job_id}`\n📦 Artifact ID: `{artifact_id}`\n\nЩоб перевірити статус: використай presentation_status\nЩоб завантажити: використай presentation_download"
                )
            else:
                error_text = resp.text[:200] if resp.text else "Unknown error"
                return ToolResult(success=False, result=None, error=f"Render failed ({resp.status_code}): {error_text}")
        except Exception as e:
            return ToolResult(success=False, result=None, error=str(e))
    
    async def _presentation_status(self, args: Dict) -> ToolResult:
        """Check presentation job status"""
        job_id = args.get("job_id")
        
        try:
            registry_url = os.getenv("ARTIFACT_REGISTRY_URL", "http://artifact-registry:9700")
            resp = await self.http_client.get(
                f"{registry_url}/jobs/{job_id}",
                timeout=10.0
            )
            if resp.status_code == 200:
                data = resp.json()
                status = data.get("status", "unknown")
                artifact_id = data.get("artifact_id")
                error = data.get("error_text", "")
                
                status_emoji = {"queued": "⏳", "running": "🔄", "done": "✅", "failed": "❌"}.get(status, "❓")
                
                result = f"{status_emoji} Статус: **{status}**\n"
                if artifact_id:
                    result += f"📦 Artifact ID: `{artifact_id}`\n"
                if status == "done":
                    result += "\n✅ Презентація готова! Використай presentation_download щоб отримати файл."
                if status == "failed" and error:
                    result += f"\n❌ Помилка: {error[:200]}"
                
                return ToolResult(success=True, result=result)
            elif resp.status_code == 404:
                return ToolResult(success=False, result=None, error="Job not found")
            else:
                return ToolResult(success=False, result=None, error=f"Status check failed: {resp.status_code}")
        except Exception as e:
            return ToolResult(success=False, result=None, error=str(e))
    
    async def _presentation_download(self, args: Dict) -> ToolResult:
        """Get download link for presentation"""
        artifact_id = args.get("artifact_id")
        file_format = args.get("format", "pptx")
        
        try:
            registry_url = os.getenv("ARTIFACT_REGISTRY_URL", "http://artifact-registry:9700")
            resp = await self.http_client.get(
                f"{registry_url}/artifacts/{artifact_id}/download?format={file_format}",
                timeout=10.0,
                follow_redirects=False
            )
            
            if resp.status_code in [200, 302, 307]:
                # Check for signed URL in response or Location header
                if resp.status_code in [302, 307]:
                    download_url = resp.headers.get("Location")
                else:
                    data = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {}
                    download_url = data.get("download_url") or data.get("url")
                
                if download_url:
                    return ToolResult(
                        success=True,
                        result=f"📥 **Посилання для завантаження ({file_format.upper()}):**\n\n{download_url}\n\n⏰ Посилання дійсне 30 хвилин."
                    )
                else:
                    # Direct binary response - artifact available
                    return ToolResult(
                        success=True,
                        result=f"✅ Файл {file_format.upper()} готовий! Завантажити можна через: {registry_url}/artifacts/{artifact_id}/download?format={file_format}"
                    )
            elif resp.status_code == 404:
                return ToolResult(success=False, result=None, error=f"Формат {file_format.upper()} ще не готовий. Спробуй пізніше.")
            else:
                return ToolResult(success=False, result=None, error=f"Download failed: {resp.status_code}")
        except Exception as e:
            return ToolResult(success=False, result=None, error=str(e))
    

    async def _crawl4ai_scrape(self, args: Dict) -> ToolResult:
        """Deep scrape a web page using Crawl4AI - PRIORITY 5"""
        url = args.get("url")
        extract_links = args.get("extract_links", True)
        extract_images = args.get("extract_images", False)
        
        if not url:
            return ToolResult(success=False, result=None, error="URL is required")
        
        try:
            crawl4ai_url = os.getenv("CRAWL4AI_URL", "http://dagi-crawl4ai-node1:11235")
            
            payload = {
                "urls": [url],
                "priority": 5,
                "session_id": f"agent_scrape_{hash(url) % 10000}"
            }
            
            resp = await self.http_client.post(
                f"{crawl4ai_url}/crawl",
                json=payload,
                timeout=60.0
            )
            
            if resp.status_code == 200:
                data = resp.json()
                results = data.get("results", []) if isinstance(data, dict) else []
                if not results and isinstance(data, dict):
                    results = [data]
                
                if results:
                    result = results[0] if isinstance(results, list) else results
                    markdown = result.get("markdown", "") or result.get("cleaned_html", "") or result.get("text", "")
                    title = result.get("title", url)
                    
                    if len(markdown) > 3000:
                        markdown = markdown[:3000] + "... (скорочено)"
                    
                    response_parts = [f"**{title}**", "", markdown]
                    
                    if extract_links:
                        links = result.get("links", [])
                        if links:
                            response_parts.append("")
                            response_parts.append("**Посилання:**")
                            for link in links[:10]:
                                if isinstance(link, dict):
                                    link_url = link.get("href", "")
                                else:
                                    link_url = str(link)
                                if link_url:
                                    response_parts.append(f"- {link_url}")
                    
                    return ToolResult(success=True, result="\n".join(response_parts))
                else:
                    return ToolResult(success=False, result=None, error="No content extracted")
            else:
                return ToolResult(success=False, result=None, error=f"Crawl failed: {resp.status_code}")
        except Exception as e:
            logger.error(f"Crawl4AI scrape failed: {e}")
            return ToolResult(success=False, result=None, error=str(e))

    async def _tts_speak(self, args: Dict) -> ToolResult:
        """Convert text to speech using Swapper TTS - PRIORITY 6"""
        text = args.get("text")
        language = args.get("language", "uk")
        
        if not text:
            return ToolResult(success=False, result=None, error="Text is required")
        
        try:
            if len(text) > 1000:
                text = text[:1000]
            
            resp = await self.http_client.post(
                f"{self.swapper_url}/tts",
                json={"text": text, "language": language},
                timeout=60.0
            )
            
            if resp.status_code == 200:
                data = resp.json()
                audio_url = data.get("audio_url") or data.get("url")
                
                if audio_url:
                    return ToolResult(success=True, result=f"Аудіо: {audio_url}")
                else:
                    return ToolResult(success=True, result="TTS completed")
            else:
                return ToolResult(success=False, result=None, error=f"TTS failed: {resp.status_code}")
        except Exception as e:
            logger.error(f"TTS failed: {e}")
            return ToolResult(success=False, result=None, error=str(e))

    async def _market_data(self, args: Dict) -> ToolResult:
        """Query real-time market data. Supports 23 symbols incl. XAU/XAG via Kraken."""
        symbol = str(args.get("symbol", "BTCUSDT")).upper()
        query_type = str(args.get("query_type", "all")).lower()

        md_url = os.getenv("MARKET_DATA_URL", "http://dagi-market-data-node1:8891")
        consumer_url = os.getenv("SENPAI_CONSUMER_URL", "http://dagi-senpai-md-consumer-node1:8892")
        binance_monitor_url = os.getenv("BINANCE_MONITOR_URL", "http://dagi-binance-bot-monitor-node1:8893")

        # Symbols served via binance-bot-monitor CCXT (not available in market-data-service WS)
        CCXT_SYMBOLS = {
            'XAUUSDT', 'XAGUSDT',  # Gold/Silver via Kraken
            'BNBUSDT', 'SOLUSDT', 'XRPUSDT', 'ADAUSDT', 'DOGEUSDT',
            'AVAXUSDT', 'DOTUSDT', 'LINKUSDT', 'POLUSDT', 'SHIBUSDT',
            'TRXUSDT', 'UNIUSDT', 'LTCUSDT', 'ATOMUSDT', 'NEARUSDT',
            'ICPUSDT', 'FILUSDT', 'APTUSDT', 'PAXGUSDT',
        }

        results: Dict[str, Any] = {}

        try:
            async with httpx.AsyncClient(timeout=10.0) as client:

                # multi mode: return all 23 symbols at once
                if query_type == "multi":
                    try:
                        resp = await client.get(f"{binance_monitor_url}/prices", timeout=12.0)
                        if resp.status_code == 200:
                            return ToolResult(success=True, result=resp.text)
                    except Exception as e:
                        return ToolResult(success=False, result=None, error=f"multi prices fetch error: {e}")

                # For XAU/XAG/PAXG and extended symbols — use binance-bot-monitor CCXT
                if symbol in CCXT_SYMBOLS or query_type == "price":
                    try:
                        resp = await client.get(f"{binance_monitor_url}/price", params={"symbol": symbol}, timeout=10.0)
                        if resp.status_code == 200:
                            data = resp.json()
                            results["price"] = {
                                "symbol": symbol,
                                "last_price": data.get("price"),
                                "bid": data.get("bid"),
                                "ask": data.get("ask"),
                                "volume_24h": data.get("volume_24h"),
                                "price_change_pct_24h": data.get("price_change_pct_24h"),
                                "high_24h": data.get("high_24h"),
                                "low_24h": data.get("low_24h"),
                                "exchange": data.get("exchange", "binance"),
                                "note": data.get("note"),
                                "error": data.get("error"),
                            }
                        else:
                            results["price_error"] = f"binance-monitor status={resp.status_code}"
                    except Exception as e:
                        results["price_error"] = str(e)

                # For BTC/ETH (WS primary) also get features from senpai-consumer
                if symbol in ('BTCUSDT', 'ETHUSDT') and query_type in ("features", "all"):
                    # WS price from market-data-service
                    if query_type == "all" and "price" not in results:
                        try:
                            resp = await client.get(f"{md_url}/latest", params={"symbol": symbol})
                            if resp.status_code == 200:
                                data = resp.json()
                                trade = data.get("latest_trade", {}) or {}
                                quote = data.get("latest_quote", {}) or {}
                                bid = quote.get("bid")
                                ask = quote.get("ask")
                                spread = None
                                if isinstance(bid, (int, float)) and isinstance(ask, (int, float)):
                                    spread = round(ask - bid, 6)
                                results["price"] = {
                                    "symbol": symbol,
                                    "last_price": trade.get("price"),
                                    "bid": bid, "ask": ask, "spread": spread,
                                    "provider": trade.get("provider"),
                                    "timestamp": trade.get("ts_recv"),
                                }
                        except Exception as e:
                            results["price_ws_error"] = str(e)
                    # Features from senpai-consumer
                    try:
                        resp = await client.get(f"{consumer_url}/features/latest", params={"symbol": symbol})
                        if resp.status_code == 200:
                            data = resp.json()
                            feats = data.get("features", {}) or {}
                            results["features"] = {
                                "symbol": symbol,
                                "mid_price": feats.get("mid"),
                                "spread_bps": round(float(feats.get("spread_bps", 0) or 0), 2),
                                "vwap_10s": round(float(feats.get("trade_vwap_10s", 0) or 0), 2),
                                "vwap_60s": round(float(feats.get("trade_vwap_60s", 0) or 0), 2),
                                "trade_count_10s": int(feats.get("trade_count_10s", 0) or 0),
                                "return_10s_pct": round(float(feats.get("return_10s", 0) or 0) * 100, 4),
                                "realized_vol_60s_pct": round(float(feats.get("realized_vol_60s", 0) or 0) * 100, 6),
                            }
                    except Exception as e:
                        results["features_error"] = str(e)

                elif symbol not in CCXT_SYMBOLS and "price" not in results:
                    # Fallback for any symbol: try market-data-service WS
                    try:
                        resp = await client.get(f"{md_url}/latest", params={"symbol": symbol})
                        if resp.status_code == 200:
                            data = resp.json()
                            trade = data.get("latest_trade", {}) or {}
                            quote = data.get("latest_quote", {}) or {}
                            bid = quote.get("bid")
                            ask = quote.get("ask")
                            results["price"] = {
                                "symbol": symbol,
                                "last_price": trade.get("price"),
                                "bid": bid, "ask": ask,
                                "provider": trade.get("provider"),
                                "timestamp": trade.get("ts_recv"),
                            }
                        else:
                            results["price_error"] = f"market-data status={resp.status_code}"
                    except Exception as e:
                        results["price_error"] = str(e)

            if not results:
                return ToolResult(success=False, result=None, error=f"No market data for {symbol}")

            return ToolResult(success=True, result=json.dumps(results, ensure_ascii=False))
        except Exception as e:
            logger.error(f"Market data tool error: {e}")
            return ToolResult(success=False, result=None, error=str(e))

    async def _binance_bots_top(self, args: Dict) -> ToolResult:
        """Get top Binance Marketplace bots via binance-bot-monitor service."""
        grid_type = str(args.get("grid_type", "SPOT")).upper()
        limit = int(args.get("limit", 10))
        binance_monitor_url = os.getenv("BINANCE_MONITOR_URL", "http://dagi-binance-bot-monitor-node1:8893")
        try:
            async with httpx.AsyncClient(timeout=10.0) as client:
                resp = await client.get(f"{binance_monitor_url}/top-bots", params={"grid_type": grid_type, "limit": limit})
                if resp.status_code == 200:
                    data = resp.json()
                    return ToolResult(success=True, result=json.dumps(data, ensure_ascii=False))
                else:
                    return ToolResult(success=False, result=None, error=f"binance-monitor status={resp.status_code}")
        except Exception as e:
            logger.error(f"binance_bots_top failed: {e}")
            return ToolResult(success=False, result=None, error=str(e))

    async def _binance_account_bots(self, args: Dict) -> ToolResult:
        """Get own Binance sub-account bots and balances via binance-bot-monitor service."""
        force_refresh = bool(args.get("force_refresh", False))
        binance_monitor_url = os.getenv("BINANCE_MONITOR_URL", "http://dagi-binance-bot-monitor-node1:8893")
        try:
            async with httpx.AsyncClient(timeout=12.0) as client:
                resp = await client.get(f"{binance_monitor_url}/account-bots", params={"force_refresh": str(force_refresh).lower()})
                if resp.status_code == 200:
                    data = resp.json()
                    return ToolResult(success=True, result=json.dumps(data, ensure_ascii=False))
                else:
                    return ToolResult(success=False, result=None, error=f"binance-monitor status={resp.status_code}")
        except Exception as e:
            logger.error(f"binance_account_bots failed: {e}")
            return ToolResult(success=False, result=None, error=str(e))

    async def _oneok_http_call(self, base_url: str, path: str, payload: Dict[str, Any], method: str = "POST") -> ToolResult:
        url = f"{base_url}{path}"
        try:
            method_up = method.upper()
            headers = {}
            if self.oneok_adapter_api_key:
                headers["Authorization"] = f"Bearer {self.oneok_adapter_api_key}"
            if method_up == "GET":
                resp = await self.http_client.get(url, params=payload, headers=headers, timeout=30.0)
            elif method_up == "PATCH":
                resp = await self.http_client.patch(url, json=payload, headers=headers, timeout=30.0)
            else:
                resp = await self.http_client.post(url, json=payload, headers=headers, timeout=30.0)

            if resp.status_code >= 400:
                body = (resp.text or "")[:500]
                return ToolResult(success=False, result=None, error=f"{url} -> HTTP {resp.status_code}: {body}")
            try:
                data = resp.json()
            except Exception:
                data = {"text": (resp.text or "")[:1000]}
            return ToolResult(success=True, result=data)
        except Exception as e:
            logger.error(f"1OK adapter call failed url={url}: {e}")
            return ToolResult(success=False, result=None, error=f"{url} unavailable: {e}")

    async def _crm_search_client(self, args: Dict) -> ToolResult:
        query = (args or {}).get("query")
        if not query:
            return ToolResult(success=False, result=None, error="query is required")
        return await self._oneok_http_call(self.oneok_crm_url, "/crm/search_client", {"query": query}, method="GET")

    async def _crm_upsert_client(self, args: Dict) -> ToolResult:
        payload = (args or {}).get("client_payload")
        if not isinstance(payload, dict):
            return ToolResult(success=False, result=None, error="client_payload is required")
        return await self._oneok_http_call(self.oneok_crm_url, "/crm/upsert_client", payload)

    async def _crm_upsert_site(self, args: Dict) -> ToolResult:
        payload = (args or {}).get("site_payload")
        if not isinstance(payload, dict):
            return ToolResult(success=False, result=None, error="site_payload is required")
        return await self._oneok_http_call(self.oneok_crm_url, "/crm/upsert_site", payload)

    async def _crm_upsert_window_unit(self, args: Dict) -> ToolResult:
        payload = (args or {}).get("window_payload")
        if not isinstance(payload, dict):
            return ToolResult(success=False, result=None, error="window_payload is required")
        return await self._oneok_http_call(self.oneok_crm_url, "/crm/upsert_window_unit", payload)

    async def _crm_create_quote(self, args: Dict) -> ToolResult:
        payload = (args or {}).get("quote_payload")
        if not isinstance(payload, dict):
            return ToolResult(success=False, result=None, error="quote_payload is required")
        return await self._oneok_http_call(self.oneok_crm_url, "/crm/create_quote", payload)

    async def _crm_update_quote(self, args: Dict) -> ToolResult:
        quote_id = (args or {}).get("quote_id")
        patch = (args or {}).get("patch")
        if not quote_id or not isinstance(patch, dict):
            return ToolResult(success=False, result=None, error="quote_id and patch are required")
        return await self._oneok_http_call(self.oneok_crm_url, "/crm/update_quote", {"quote_id": quote_id, "patch": patch}, method="PATCH")

    async def _crm_create_job(self, args: Dict) -> ToolResult:
        payload = (args or {}).get("job_payload")
        if not isinstance(payload, dict):
            return ToolResult(success=False, result=None, error="job_payload is required")
        return await self._oneok_http_call(self.oneok_crm_url, "/crm/create_job", payload)

    async def _calc_window_quote(self, args: Dict) -> ToolResult:
        payload = (args or {}).get("input_payload")
        if not isinstance(payload, dict):
            return ToolResult(success=False, result=None, error="input_payload is required")
        return await self._oneok_http_call(self.oneok_calc_url, "/calc/window_quote", payload)

    async def _docs_render_quote_pdf(self, args: Dict) -> ToolResult:
        quote_id = (args or {}).get("quote_id")
        quote_payload = (args or {}).get("quote_payload")
        if not quote_id and not isinstance(quote_payload, dict):
            return ToolResult(success=False, result=None, error="quote_id or quote_payload is required")
        payload = {"quote_id": quote_id, "quote_payload": quote_payload}
        return await self._oneok_http_call(self.oneok_docs_url, "/docs/render_quote_pdf", payload)

    async def _docs_render_invoice_pdf(self, args: Dict) -> ToolResult:
        payload = (args or {}).get("invoice_payload")
        if not isinstance(payload, dict):
            return ToolResult(success=False, result=None, error="invoice_payload is required")
        return await self._oneok_http_call(self.oneok_docs_url, "/docs/render_invoice_pdf", payload)

    async def _schedule_propose_slots(self, args: Dict) -> ToolResult:
        payload = (args or {}).get("params")
        if not isinstance(payload, dict):
            return ToolResult(success=False, result=None, error="params is required")
        return await self._oneok_http_call(self.oneok_schedule_url, "/schedule/propose_slots", payload)

    async def _schedule_confirm_slot(self, args: Dict) -> ToolResult:
        job_id = (args or {}).get("job_id")
        slot = (args or {}).get("slot")
        if not job_id or slot is None:
            return ToolResult(success=False, result=None, error="job_id and slot are required")
        return await self._oneok_http_call(self.oneok_schedule_url, "/schedule/confirm_slot", {"job_id": job_id, "slot": slot})

    async def _data_governance_tool(self, args: Dict) -> ToolResult:
        """
        Data Governance & Privacy scanner.
        Read-only; evidence is always masked before returning.
        """
        action = (args or {}).get("action", "scan_repo")
        params = {k: v for k, v in (args or {}).items() if k != "action"}
        try:
            from data_governance import scan_data_governance_dict
            result = scan_data_governance_dict(action=action, params=params)
            return ToolResult(success=True, result=result)
        except Exception as e:
            logger.exception("data_governance_tool error")
            return ToolResult(success=False, result=None, error=f"Data governance error: {e}")

    async def _cost_analyzer_tool(self, args: Dict) -> ToolResult:
        """
        Cost & Resource Analyzer (FinOps MVP).

        Reads from AuditStore and computes cost_units aggregations / anomalies.
        No payload access — only aggregation parameters.
        """
        action = (args or {}).get("action", "top")
        params = {k: v for k, v in (args or {}).items() if k != "action"}
        try:
            from cost_analyzer import analyze_cost_dict
            report = analyze_cost_dict(action=action, params=params)
            return ToolResult(success=True, result=report)
        except Exception as e:
            logger.exception("cost_analyzer_tool error")
            return ToolResult(success=False, result=None, error=f"Cost analyzer error: {e}")

    async def _dependency_scanner_tool(self, args: Dict) -> ToolResult:
        """
        Dependency & Supply Chain Scanner.

        Scans Python/Node dependencies for known vulnerabilities (OSV),
        outdated packages, and license policy violations.

        Security:
        - Read-only (no writes except optional cache update in online mode)
        - Evidence is redacted for secrets
        - Payload not logged; only hash + counts in audit
        - Timeout enforced
        """
        action = (args or {}).get("action", "scan")
        if action != "scan":
            return ToolResult(success=False, result=None,
                              error=f"Unknown action '{action}'. Valid: scan")

        targets = (args or {}).get("targets") or ["python", "node"]
        vuln_mode = (args or {}).get("vuln_mode", "offline_cache")
        fail_on = (args or {}).get("fail_on") or ["CRITICAL", "HIGH"]
        timeout_sec = float((args or {}).get("timeout_sec", 40))

        REPO_ROOT = os.getenv("REPO_ROOT", "/Users/apple/github-projects/microdao-daarion")
        cache_path = os.path.join(REPO_ROOT, "ops", "cache", "osv_cache.json")

        try:
            from dependency_scanner import scan_dependencies_dict
            report = scan_dependencies_dict(
                repo_root=REPO_ROOT,
                targets=targets,
                vuln_sources={
                    "osv": {
                        "enabled": True,
                        "mode": vuln_mode,
                        "cache_path": cache_path,
                    }
                },
                severity_thresholds={"fail_on": fail_on, "warn_on": ["MEDIUM"]},
                timeout_sec=min(timeout_sec, 60.0),
            )
            return ToolResult(success=True, result=report)
        except Exception as e:
            logger.exception("Dependency scanner error")
            return ToolResult(success=False, result=None,
                              error=f"Dependency scan failed: {e}")

    async def _drift_analyzer_tool(self, args: Dict) -> ToolResult:
        """
        Drift Analyzer Tool.

        Analyzes drift between 'sources of truth' (docs/inventory/config) and
        actual repo state (compose files, code routes, NATS usage, handlers).

        Security:
        - Read-only: never writes to repo
        - Evidence is redacted for secrets
        - Scans only within REPO_ROOT, excludes node_modules/.git/vendor
        - Timeout enforced
        """
        action = (args or {}).get("action", "analyze")
        categories = (args or {}).get("categories") or None
        timeout_sec = float((args or {}).get("timeout_sec", 25))

        if action != "analyze":
            return ToolResult(
                success=False, result=None,
                error=f"Unknown action '{action}'. Valid: analyze"
            )

        REPO_ROOT = os.getenv("REPO_ROOT", "/Users/apple/github-projects/microdao-daarion")

        try:
            from drift_analyzer import analyze_drift_dict
            report = analyze_drift_dict(
                repo_root=REPO_ROOT,
                categories=categories,
                timeout_sec=min(timeout_sec, 30.0),
            )
            return ToolResult(success=True, result=report)
        except Exception as e:
            logger.exception("Drift analyzer error")
            return ToolResult(success=False, result=None, error=f"Drift analysis failed: {e}")

    async def _repo_tool(self, args: Dict) -> ToolResult:
        """
        Read-only repository access tool.
        
        Actions:
        - tree: Show directory structure
        - read: Read file contents
        - search: Search for text in files
        - metadata: Show git metadata
        
        Security:
        - Path traversal protection
        - Symlink escape protection
        - Size limits
        - Secret masking
        """
        import os
        import re
        import subprocess
        from pathlib import Path
        
        action = (args or {}).get("action", "tree")
        path = (args or {}).get("path", ".")
        depth = min((args or {}).get("depth", 3), 10)  # Max depth 10
        glob_pattern = (args or {}).get("glob")
        query = (args or {}).get("query")
        limit = min((args or {}).get("limit", 50), 200)
        max_bytes = min((args or {}).get("max_bytes", 204800), 1024 * 1024)  # Max 1MB
        start_line = (args or {}).get("start_line", 1)
        end_line = (args or {}).get("end_line")
        
        # Get repo root from environment or default to current directory
        repo_root = os.environ.get("REPO_ROOT", os.getcwd())
        
        # Resolve the full path and check for traversal
        try:
            # Normalize and resolve the path
            full_path = os.path.normpath(os.path.join(repo_root, path))
            
            # Check 1: Path must start with repo root (prevent traversal with ..)
            if not full_path.startswith(os.path.realpath(repo_root)):
                return ToolResult(
                    success=False,
                    result=None,
                    error="Path traversal detected. Access denied."
                )
            
            # Check 2: Check for symlink escape
            resolved_path = os.path.realpath(full_path)
            if not resolved_path.startswith(os.path.realpath(repo_root)):
                return ToolResult(
                    success=False,
                    result=None,
                    error="Symlink escape detected. Access denied."
                )
            
            # Check 3: Path must exist
            if not os.path.exists(full_path):
                return ToolResult(
                    success=False,
                    result=None,
                    error=f"Path does not exist: {path}"
                )
                
        except Exception as e:
            return ToolResult(
                success=False,
                result=None,
                error=f"Path validation error: {str(e)}"
            )
        
        # Secret masking regex
        SECRET_PATTERNS = [
            (re.compile(r'(?i)(api[_-]?key|token|secret|password|passwd|pwd|auth)[^=\s]*\s*=\s*[\'"]?(.+?)[\'"]?\s*$', re.MULTILINE), r'\1=***'),
            (re.compile(r'(?i)(bearer|jwt|oauth)[^=\s]*\s*[\'"]?(.+?)[\'"]?\s*$', re.MULTILINE), r'\1=***'),
            (re.compile(r'-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----'), '-----BEGIN PRIVATE KEY-----'),
        ]
        
        def mask_secrets(content: str) -> str:
            """Mask secrets in content"""
            for pattern, replacement in SECRET_PATTERNS:
                content = pattern.sub(replacement, content)
            return content
        
        def should_mask_file(path: str) -> bool:
            """Check if file should be masked (env, secrets, etc.)"""
            filename = os.path.basename(path).lower()
            mask_extensions = ['.env', '.env.local', '.env.production', '.env.development']
            mask_names = ['secrets', 'credentials', 'keys', 'tokens', 'passwords']
            
            if any(filename.endswith(ext) for ext in mask_extensions):
                return True
            if any(name in filename for name in mask_names):
                return True
            return False
        
        try:
            if action == "tree":
                # Build tree structure
                def build_tree(base_path: str, current_depth: int) -> dict:
                    if current_depth > depth:
                        return {"_truncated": True}
                    
                    result = {}
                    try:
                        entries = os.listdir(base_path)
                    except PermissionError:
                        return {"_error": "Permission denied"}
                    
                    # Sort entries - dirs first, then files
                    dirs = []
                    files = []
                    for entry in entries:
                        # Skip hidden files and common ignore patterns
                        if entry.startswith('.') or entry in ['node_modules', '__pycache__', '.git', 'venv', '.venv']:
                            continue
                        
                        full_entry_path = os.path.join(base_path, entry)
                        if os.path.isdir(full_entry_path):
                            dirs.append(entry)
                        else:
                            files.append(entry)
                    
                    for d in sorted(dirs):
                        result[d] = build_tree(os.path.join(base_path, d), current_depth + 1)
                    
                    # Limit files shown
                    for f in sorted(files)[:50]:  # Max 50 files per directory
                        result[f] = "[file]"
                    
                    return result
                
                tree = build_tree(full_path, 0)
                return ToolResult(success=True, result={"tree": tree, "path": path})
            
            elif action == "read":
                # Check if it's a file
                if not os.path.isfile(full_path):
                    return ToolResult(
                        success=False,
                        result=None,
                        error="Path is not a file"
                    )
                
                # Check file size
                file_size = os.path.getsize(full_path)
                if file_size > max_bytes:
                    return ToolResult(
                        success=False,
                        result=None,
                        error=f"File too large: {file_size} bytes (max: {max_bytes})"
                    )
                
                # Check if should mask (env files, secrets, etc.)
                if should_mask_file(full_path):
                    return ToolResult(
                        success=True,
                        result={
                            "path": path,
                            "content": "[FILE MASKED - contains secrets]",
                            "size": file_size,
                            "masked": True
                        }
                    )
                
                # Read file content
                with open(full_path, 'r', encoding='utf-8', errors='ignore') as f:
                    lines = f.readlines()
                
                # Apply line limits
                if end_line:
                    lines = lines[start_line-1:end_line]
                else:
                    lines = lines[start_line-1:start_line-1 + 1000]  # Default max 1000 lines
                
                content = ''.join(lines)
                
                # Mask any secrets in the content
                content = mask_secrets(content)
                
                return ToolResult(
                    success=True,
                    result={
                        "path": path,
                        "content": content,
                        "lines": len(lines),
                        "start_line": start_line,
                        "end_line": start_line + len(lines) - 1
                    }
                )
            
            elif action == "search":
                if not query:
                    return ToolResult(
                        success=False,
                        result=None,
                        error="Query is required for search action"
                    )
                
                # Use grep via subprocess with limits
                cmd = [
                    "grep", "-r", "-n", "--max-count=" + str(limit),
                    "--include=" + (glob_pattern if glob_pattern else "*"),
                    query, path
                ]
                
                try:
                    result = subprocess.run(
                        cmd,
                        cwd=repo_root,
                        capture_output=True,
                        text=True,
                        timeout=10
                    )
                    
                    # Parse grep output
                    matches = []
                    for line in result.stdout.strip().split('\n'):
                        if not line:
                            continue
                        # Format: filename:line:content
                        parts = line.split(':', 2)
                        if len(parts) >= 2:
                            matches.append({
                                "file": parts[0],
                                "line": parts[1] if len(parts) > 1 else "",
                                "content": parts[2] if len(parts) > 2 else ""
                            })
                    
                    # Mask secrets in matches
                    for m in matches:
                        m["content"] = mask_secrets(m["content"])
                    
                    return ToolResult(
                        success=True,
                        result={
                            "query": query,
                            "path": path,
                            "matches": matches,
                            "count": len(matches)
                        }
                    )
                except subprocess.TimeoutExpired:
                    return ToolResult(
                        success=False,
                        result=None,
                        error="Search timed out (>10s)"
                    )
                except Exception as e:
                    return ToolResult(
                        success=False,
                        result=None,
                        error=f"Search error: {str(e)}"
                    )
            
            elif action == "metadata":
                # Get git metadata
                meta = {
                    "path": path,
                    "repo_root": repo_root
                }
                
                try:
                    # Git commit hash
                    result = subprocess.run(
                        ["git", "rev-parse", "HEAD"],
                        cwd=repo_root,
                        capture_output=True,
                        text=True,
                        timeout=5
                    )
                    if result.returncode == 0:
                        meta["commit"] = result.stdout.strip()
                except:
                    pass
                
                try:
                    # Git branch
                    result = subprocess.run(
                        ["git", "branch", "--show-current"],
                        cwd=repo_root,
                        capture_output=True,
                        text=True,
                        timeout=5
                    )
                    if result.returncode == 0:
                        meta["branch"] = result.stdout.strip()
                except:
                    pass
                
                try:
                    # Git status
                    result = subprocess.run(
                        ["git", "status", "--porcelain"],
                        cwd=repo_root,
                        capture_output=True,
                        text=True,
                        timeout=5
                    )
                    if result.returncode == 0:
                        meta["dirty"] = bool(result.stdout.strip())
                except:
                    pass
                
                return ToolResult(success=True, result=meta)
            
            else:
                return ToolResult(
                    success=False,
                    result=None,
                    error=f"Unknown action: {action}. Valid: tree, read, search, metadata"
                )
                
        except Exception as e:
            logger.error(f"Repo tool error: {e}")
            return ToolResult(
                success=False,
                result=None,
                error=f"Internal error: {str(e)}"
            )
    
    async def _pr_reviewer_tool(self, args: Dict) -> ToolResult:
        """
        PR/Diff Reviewer Tool.
        
        Analyzes code changes, finds security issues, blocking problems, regression risks.
        Supports blocking_only and full_review modes.
        
        Security:
        - Does NOT log diff.text (only hash, file count, char count)
        - Masks secrets in evidence
        - Enforces max_files and max_chars limits
        """
        import re
        import hashlib
        
        mode = (args or {}).get("mode", "full_review")
        context = (args or {}).get("context", {})
        diff_data = (args or {}).get("diff", {})
        options = (args or {}).get("options", {})
        
        diff_text = diff_data.get("text", "")
        max_files = diff_data.get("max_files", 200)
        max_chars = diff_data.get("max_chars", 400000)
        
        # Log only metadata (no diff content)
        diff_hash = hashlib.sha256(diff_text.encode()).hexdigest()[:16]
        file_count = diff_text.count("diff --git")
        line_count = diff_text.count("\n")
        
        logger.info(f"PR Review: mode={mode}, files={file_count}, lines={line_count}, chars={len(diff_text)}, hash={diff_hash}")
        
        # Enforce limits
        if len(diff_text) > max_chars:
            return ToolResult(
                success=False,
                result=None,
                error=f"Diff too large: {len(diff_text)} chars (max: {max_chars})"
            )
        
        if file_count > max_files:
            return ToolResult(
                success=False,
                result=None,
                error=f"Too many files: {file_count} (max: {max_files})"
            )
        
        # Secret masking for evidence
        SECRET_PATTERN = re.compile(
            r'(?i)'
            r'(api[_-]?key|token|secret|password|passwd|pwd|auth|bearer|jwt|oauth|private[_-]?key)'
            r'[\s=:]+[\'"`]?([a-zA-Z0-9_\-\.]{8,})[\'"`]?',
            re.MULTILINE
        )
        
        def mask_secret(text: str) -> str:
            """Mask secret values in text"""
            def replacer(match):
                key = match.group(1)
                return f"{key}=***"
            return SECRET_PATTERN.sub(replacer, text)
        
        # Blocking patterns for security analysis
        BLOCKING_PATTERNS = [
            # Secrets in diff
            (r'(?i)(api[_-]?key|token|secret|password)\s*=\s*[\'"]?[a-zA-Z0-9_\-]{20,}[\'"]?', 
             "SECRETS", "Secret detected in diff"),
            
            # Auth bypass
            (r'(?i)(if\s+.*auth.*:\s*return\s+True|#.*skip.*auth)',
             "AUTH_BYPASS", "Potential auth bypass"),
            
            # RCE/eval
            (r'(?i)(eval\(|exec\(|subprocess\.call|os\.system|shell=True)',
             "RCE", "Potential remote code execution"),
            
            # SQL injection
            (r'(?i)(execute\(|cursor\.execute|sql\s*\+|f"SELECT|\'.format\()',
             "SQL_INJECTION", "Potential SQL injection"),
            
            # Hardcoded credentials
            (r'(?i)(password\s*=\s*[\'"][^\'"]+[\'"]|pwd\s*=\s*[\'"][^\'"]+[\'"])',
             "HARDCODED_CREDS", "Hardcoded credentials"),
            
            # Disabled security
            (r'(?i)(#.*disable.*security|# noqa|SkipTest|skip_auth)',
             "SECURITY_DISABLED", "Security check disabled"),
             
            # Breaking API changes (simple heuristics)
            (r'(?i)(def\s+\w+\([^)]*\):[^}]*raise\s+NotImplemented)',
             "BREAKING_API", "Breaking API change without version"),
        ]
        
        # Non-blocking patterns
        NONBLOCKING_PATTERNS = [
            (r'(?i)(# TODO|# FIXME|# XXX)', "TODO", "Technical debt"),
            (r'(?i)(except:.*pass|except Exception: pass)', "BROAD_EXCEPTION", "Broad exception handling"),
            (r'(?i)(print\(|logger\.)', "LOGGING", "Print/logging statement"),
            (r'(?i)(time\.sleep\(|asyncio\.sleep\()', "BLOCKING_SLEEP", "Blocking sleep"),
        ]
        
        blocking_issues = []
        issues = []
        security_findings = []
        
        # Parse diff and analyze
        files = diff_text.split("diff --git")
        issue_id_counter = {"blocking": 1, "nonblocking": 1}
        
        for file_diff in files[1:]:  # Skip first empty split
            if not file_diff.strip():
                continue
            
            # Extract file path
            file_match = re.search(r'b/([^\s]+)', file_diff)
            file_path = file_match.group(1) if file_match else "unknown"
            
            # Extract changed lines
            added_lines = re.findall(r'^\+[^+].*$', file_diff, re.MULTILINE)
            removed_lines = re.findall(r'^-[^-].*$', file_diff, re.MULTILINE)
            
            # Check for blocking issues
            for pattern, issue_type, title in BLOCKING_PATTERNS:
                matches = re.finditer(pattern, file_diff)
                for match in matches:
                    # Get line context
                    line_start = file_diff.rfind('\n', 0, match.start()) + 1
                    line_end = file_diff.find('\n', match.start())
                    line_num = file_diff[:match.start()].count('\n') + 1
                    
                    evidence = file_diff[line_start:line_end].strip()[:200]
                    evidence = mask_secret(evidence)  # Mask secrets in evidence
                    
                    issue = {
                        "id": f"PRR-{issue_id_counter['blocking']:03d}",
                        "title": title,
                        "severity": "critical" if issue_type in ["SECRETS", "RCE", "SQL_INJECTION"] else "high",
                        "file": file_path,
                        "lines": f"L{line_num}",
                        "evidence": evidence,
                        "why_it_matters": self._get_why_matters(issue_type),
                        "fix_suggestion": self._get_fix_suggestion(issue_type)
                    }
                    
                    if issue_type in ["SECRETS", "RCE", "SQL_INJECTION"]:
                        security_findings.append({
                            "type": issue_type.lower(),
                            "finding": title,
                            "mitigation": self._get_fix_suggestion(issue_type)
                        })
                    
                    blocking_issues.append(issue)
                    issue_id_counter['blocking'] += 1
            
            # Check for non-blocking issues (only in full_review mode)
            if mode == "full_review":
                for pattern, issue_type, title in NONBLOCKING_PATTERNS:
                    matches = re.finditer(pattern, file_diff)
                    for match in matches:
                        line_num = file_diff[:match.start()].count('\n') + 1
                        
                        evidence = file_diff[max(0, match.start()-20):match.end()+20].strip()[:200]
                        
                        issues.append({
                            "id": f"PRR-{100 + issue_id_counter['nonblocking']:03d}",
                            "title": title,
                            "severity": "medium",
                            "file": file_path,
                            "lines": f"L{line_num}",
                            "why_it_matters": self._get_why_matters(issue_type),
                            "fix_suggestion": self._get_fix_suggestion(issue_type)
                        })
                        issue_id_counter['nonblocking'] += 1
                
                # Check for missing tests
                if any(x in file_path for x in ['.py', '/services/', '/api/']) and 'test' not in file_path.lower():
                    if not any('test' in f for f in files):
                        issues.append({
                            "id": f"PRR-{100 + issue_id_counter['nonblocking']:03d}",
                            "title": "No test files in diff",
                            "severity": "info",
                            "file": file_path,
                            "why_it_matters": "No test coverage for changes",
                            "fix_suggestion": "Add unit/integration tests for the changes"
                        })
                        issue_id_counter['nonblocking'] += 1
        
        # Build response
        risk_score = min(100, len(blocking_issues) * 25 + len(issues) * 5)
        security_score = max(0, 100 - len(security_findings) * 30)
        
        # Generate summary
        if blocking_issues:
            summary = f"🚫 {len(blocking_issues)} blocking issue(s) found. {' '.join([i['title'] for i in blocking_issues[:3]])}"
        elif issues:
            summary = f"⚠️ {len(issues)} non-blocking issue(s) found. Review recommended."
        else:
            summary = "✅ No issues detected. Code looks good."
        
        result = {
            "summary": summary,
            "score": {
                "risk": risk_score,
                "maintainability": max(0, 100 - risk_score),
                "security": security_score,
                "test_coverage": 50 if any('test' in f for f in files) else 20
            },
            "blocking_issues": blocking_issues if mode == "blocking_only" else blocking_issues,
            "issues": [] if mode == "blocking_only" else issues,
            "regression_risks": self._assess_regression_risks(files, diff_text) if options.get("include_deploy_risks", True) else [],
            "security_findings": security_findings,
            "tests_checklist": self._generate_tests_checklist(file_count) if options.get("include_tests_checklist", True) else [],
            "deploy_checklist": self._generate_deploy_checklist() if options.get("include_deploy_risks", True) else [],
            "questions_for_author": self._generate_questions(blocking_issues, issues)
        }
        
        return ToolResult(success=True, result=result)
    
    def _get_why_matters(self, issue_type: str) -> str:
        """Get explanation of why this issue matters"""
        reasons = {
            "SECRETS": "Secrets in code can be exposed in logs, backups, and version control",
            "RCE": "Remote code execution allows attackers to run arbitrary commands",
            "SQL_INJECTION": "SQL injection can lead to data breach or data loss",
            "AUTH_BYPASS": "Auth bypass allows unauthorized access to protected resources",
            "HARDCODED_CREDS": "Hardcoded credentials are visible in source code",
            "SECURITY_DISABLED": "Disabling security checks creates vulnerabilities",
            "BREAKING_API": "Breaking API changes without versioning cause service disruptions",
            "TODO": "Technical debt should be tracked and addressed",
            "BROAD_EXCEPTION": "Catching all exceptions hides errors",
            "LOGGING": "Excessive logging may expose sensitive data",
            "BLOCKING_SLEEP": "Blocking sleep affects performance",
        }
        return reasons.get(issue_type, "This pattern may cause issues")
    
    def _get_fix_suggestion(self, issue_type: str) -> str:
        """Get fix suggestion for issue type"""
        fixes = {
            "SECRETS": "Use environment variables or secrets manager",
            "RCE": "Use parameterized queries, avoid shell=True",
            "SQL_INJECTION": "Use ORM or parameterized queries",
            "AUTH_BYPASS": "Ensure proper authentication checks",
            "HARDCODED_CREDS": "Move to environment variables or config",
            "SECURITY_DISABLED": "Re-enable security checks or document why disabled",
            "BREAKING_API": "Add version prefix or maintain backward compatibility",
            "TODO": "Create issue to track this task",
            "BROAD_EXCEPTION": "Catch specific exceptions",
            "LOGGING": "Use structured logging, avoid sensitive data",
            "BLOCKING_SLEEP": "Use async sleep or background tasks",
        }
        return fixes.get(issue_type, "Review and fix as appropriate")
    
    def _assess_regression_risks(self, files: list, diff_text: str) -> list:
        """Assess regression risks"""
        risks = []
        
        # Check for gateway/router changes
        if any('router' in f or 'gateway' in f for f in files):
            risks.append({
                "area": "gateway",
                "risk": "Changes to router may affect routing logic",
                "mitigation": "Test all known routes after deployment"
            })
        
        # Check for DB migrations
        if 'migration' in diff_text.lower() or 'alter table' in diff_text.lower():
            risks.append({
                "area": "db",
                "risk": "Database schema changes may cause downtime",
                "mitigation": "Use rolling migrations, add fallback"
            })
        
        # Check for auth changes
        if 'auth' in diff_text.lower() or 'permission' in diff_text.lower():
            risks.append({
                "area": "auth",
                "risk": "Auth changes may lock out users",
                "mitigation": "Test thoroughly, have rollback plan"
            })
        
        return risks
    
    def _generate_tests_checklist(self, file_count: int) -> list:
        """Generate test checklist"""
        checklist = [
            {"type": "unit", "item": "Add unit tests for new functions"},
            {"type": "integration", "item": "Add integration tests for API endpoints"},
        ]
        
        if file_count > 10:
            checklist.append({"type": "e2e", "item": "Consider E2E tests for critical flows"})
        
        return checklist
    
    def _generate_deploy_checklist(self) -> list:
        """Generate deploy checklist"""
        return [
            {"item": "Verify CI/CD pipeline passes"},
            {"item": "Check backward compatibility"},
            {"item": "Review security findings"},
            {"item": "Ensure rollback plan is ready"},
        ]
    
    def _generate_questions(self, blocking: list, issues: list) -> list:
        """Generate questions for PR author"""
        questions = []
        
        if any('SECRETS' in i.get('title', '') for i in blocking):
            questions.append("How are secrets being managed?")
        
        if any('AUTH' in i.get('title', '') for i in blocking):
            questions.append("Are authentication changes properly tested?")
        
        if len(issues) > 5:
            questions.append("Can we address the non-blocking issues in a follow-up?")
        
        if not questions:
            questions.append("Is this ready for merge?")
        
        return questions
    
    async def _contract_tool(self, args: Dict) -> ToolResult:
        """
        Contract Tool - OpenAPI/JSON Schema validation and diff.
        
        Actions:
        - lint_openapi: Static quality checks
        - diff_openapi: Breaking changes detection
        - generate_client_stub: Generate client stubs (MVP - basic)
        
        Security:
        - Does NOT log full OpenAPI specs
        - Enforces max_chars limit
        - Safe YAML/JSON parsing
        """
        import re
        import hashlib
        import yaml
        import json
        
        action = (args or {}).get("action", "lint_openapi")
        inputs = (args or {}).get("inputs", {})
        options = (args or {}).get("options", {})
        
        max_chars = options.get("max_chars", 800000)
        service_name = options.get("service_name", "unknown")
        strict = options.get("strict", False)
        fail_on_breaking = options.get("fail_on_breaking", False)
        
        # Log only metadata (no spec content)
        base_val = inputs.get("base", {}).get("value", "")
        head_val = inputs.get("head", {}).get("value", "")
        
        base_hash = hashlib.sha256(base_val.encode()).hexdigest()[:16] if base_val else "none"
        head_hash = hashlib.sha256(head_val.encode()).hexdigest()[:16] if head_val else "none"
        
        logger.info(f"Contract Tool: action={action}, service={service_name}, base_hash={base_hash}, head_hash={head_hash}")
        
        # Enforce limits
        if base_val and len(base_val) > max_chars:
            return ToolResult(success=False, result=None, error=f"Base spec too large: {len(base_val)} chars (max: {max_chars})")
        if head_val and len(head_val) > max_chars:
            return ToolResult(success=False, result=None, error=f"Head spec too large: {len(head_val)} chars (max: {max_chars})")
        
        # Parse input helper
        def parse_spec(source: str, value: str, format_type: str) -> Optional[Dict]:
            """Parse OpenAPI spec from text or repo path"""
            if not value:
                return None
            
            if source == "repo_path":
                # Would use repo_tool here for MVP just return error asking for text
                return ToolResult(success=False, result=None, error="repo_path source not implemented in MVP, use text source")
            
            try:
                if format_type == "openapi_yaml" or "yaml" in value.lower()[:100]:
                    return yaml.safe_load(value)
                else:
                    return json.loads(value)
            except Exception as e:
                return None
        
        format_type = inputs.get("format", "openapi_yaml")
        
        # Execute action
        if action == "lint_openapi":
            return await self._lint_openapi(inputs, options, format_type)
        elif action == "diff_openapi":
            return await self._diff_openapi(inputs, options, format_type, fail_on_breaking)
        elif action == "generate_client_stub":
            return await self._generate_client_stub(inputs, options, format_type)
        else:
            return ToolResult(success=False, result=None, error=f"Unknown action: {action}")
    
    async def _lint_openapi(self, inputs: Dict, options: Dict, format_type: str) -> ToolResult:
        """Lint OpenAPI spec for quality issues"""
        import yaml
        import json
        
        spec_value = inputs.get("base", {}).get("value") or inputs.get("head", {}).get("value", "")
        
        if not spec_value:
            return ToolResult(success=False, result=None, error="No spec provided")
        
        # Parse spec
        try:
            if "yaml" in format_type:
                spec = yaml.safe_load(spec_value)
            else:
                spec = json.loads(spec_value)
        except Exception as e:
            return ToolResult(success=False, result=None, error=f"Failed to parse spec: {str(e)}")
        
        lint_issues = []
        issue_id = 1
        
        # Lint rules
        paths = spec.get("paths", {})
        
        # Rule 1: Check for endpoints without operationId
        for path, methods in paths.items():
            if not isinstance(methods, dict):
                continue
            for method, details in methods.items():
                if method not in ["get", "post", "put", "delete", "patch", "options", "head"]:
                    continue
                
                if not details.get("operationId"):
                    lint_issues.append({
                        "id": f"OAL-{issue_id:03d}",
                        "severity": "error",
                        "message": f"Endpoint {method.upper()} {path} missing operationId",
                        "location": f"paths.{path}.{method}"
                    })
                    issue_id += 1
                
                # Rule 2: Check for POST/PUT without requestBody schema
                if method in ["post", "put", "patch"]:
                    if not details.get("requestBody"):
                        lint_issues.append({
                            "id": f"OAL-{issue_id:03d}",
                            "severity": "warning",
                            "message": f"Endpoint {method.upper()} {path} missing requestBody",
                            "location": f"paths.{path}.{method}"
                        })
                        issue_id += 1
                
                # Rule 3: Check for missing 2xx responses
                responses = details.get("responses", {})
                has_2xx = any(code.startswith("2") for code in responses.keys())
                if not has_2xx and responses:
                    lint_issues.append({
                        "id": f"OAL-{issue_id:03d}",
                        "severity": "warning",
                        "message": f"Endpoint {method.upper()} {path} has no 2xx response",
                        "location": f"paths.{path}.{method}.responses"
                    })
                    issue_id += 1
        
        # Rule 4: Check for unresolved $refs
        spec_str = json.dumps(spec)
        ref_matches = re.findall(r'\$ref:\s*["\']?([^"\'#]+)', spec_str)
        for ref in ref_matches:
            if "#" not in ref:  # External refs not resolved
                lint_issues.append({
                    "id": f"OAL-{issue_id:03d}",
                    "severity": "warning",
                    "message": f"Unresolved external reference: {ref}",
                    "location": "global"
                })
                issue_id += 1
        
        # Rule 5: Check for missing descriptions on critical paths
        critical_paths = ["/v1/auth", "/v1/users", "/v1/payments", "/v1/admin"]
        for path in paths:
            if any(critical in path for critical in critical_paths):
                for method, details in paths[path].items():
                    if method in ["get", "post", "put", "delete"]:
                        if not details.get("description") and not details.get("summary"):
                            lint_issues.append({
                                "id": f"OAL-{issue_id:03d}",
                                "severity": "info",
                                "message": f"Critical endpoint {method.upper()} {path} missing description",
                                "location": f"paths.{path}.{method}"
                            })
                            issue_id += 1
        
        # Generate summary
        errors = [i for i in lint_issues if i["severity"] == "error"]
        warnings = [i for i in lint_issues if i["severity"] == "warning"]
        
        if errors:
            summary = f"🚫 {len(errors)} error(s), {len(warnings)} warning(s) found"
        elif warnings:
            summary = f"⚠️ {len(warnings)} warning(s) found"
        else:
            summary = "✅ OpenAPI spec looks good"
        
        result = {
            "summary": summary,
            "lint": lint_issues,
            "error_count": len(errors),
            "warning_count": len(warnings),
            "info_count": len([i for i in lint_issues if i["severity"] == "info"]),
            "compat_score": {
                "errors": len(errors),
                "warnings": len(warnings),
                "coverage": max(0, 100 - len(errors) * 10 - len(warnings) * 2)
            }
        }
        
        return ToolResult(success=True, result=result)
    
    async def _diff_openapi(self, inputs: Dict, options: Dict, format_type: str, fail_on_breaking: bool) -> ToolResult:
        """Compare two OpenAPI specs and detect breaking changes"""
        import yaml
        import json
        
        base_val = inputs.get("base", {}).get("value", "")
        head_val = inputs.get("head", {}).get("value", "")
        
        if not base_val or not head_val:
            return ToolResult(success=False, result=None, error="Both base and head specs required for diff")
        
        # Parse specs
        try:
            base_spec = yaml.safe_load(base_val) if "yaml" in format_type else json.loads(base_val)
            head_spec = yaml.safe_load(head_val) if "yaml" in format_type else json.loads(head_val)
        except Exception as e:
            return ToolResult(success=False, result=None, error=f"Failed to parse specs: {str(e)}")
        
        breaking = []
        non_breaking = []
        issue_id = {"breaking": 1, "nonbreaking": 1}
        
        base_paths = base_spec.get("paths", {})
        head_paths = head_spec.get("paths", {})
        
        # Check for removed endpoints
        for path, methods in base_paths.items():
            if path not in head_paths:
                breaking.append({
                    "id": f"OAC-{issue_id['breaking']:03d}",
                    "type": "endpoint_removed",
                    "path": path,
                    "method": "ALL",
                    "location": f"paths.{path}",
                    "why_it_breaks": f"Endpoint {path} was completely removed",
                    "suggested_fix": "Ensure backward compatibility or version the API"
                })
                issue_id['breaking'] += 1
                continue
            
            base_methods = {k: v for k, v in methods.items() if k in ["get", "post", "put", "delete", "patch"]}
            head_methods = {k: v for k, v in head_paths[path].items() if k in ["get", "post", "put", "delete", "patch"]}
            
            # Check for removed methods
            for method in base_methods:
                if method not in head_methods:
                    breaking.append({
                        "id": f"OAC-{issue_id['breaking']:03d}",
                        "type": "endpoint_removed",
                        "path": path,
                        "method": method.upper(),
                        "location": f"paths.{path}.{method}",
                        "why_it_breaks": f"Method {method.upper()} was removed from {path}",
                        "suggested_fix": "Deprecate instead of removing"
                    })
                    issue_id['breaking'] += 1
            
            # Check for required parameters added
            for method, base_details in base_methods.items():
                if method not in head_methods:
                    continue
                
                head_details = head_methods[method]
                base_params = base_details.get("parameters", [])
                head_params = head_details.get("parameters", [])
                
                base_required = {p["name"] for p in base_params if p.get("required", False)}
                head_required = {p["name"] for p in head_params if p.get("required", False)}
                
                # New required params = breaking
                for param in head_required - base_required:
                    breaking.append({
                        "id": f"OAC-{issue_id['breaking']:03d}",
                        "type": "required_added",
                        "path": path,
                        "method": method.upper(),
                        "location": f"paths.{path}.{method}.parameters.{param}",
                        "why_it_breaks": f"Required parameter '{param}' was added",
                        "suggested_fix": "Make parameter optional or use default value"
                    })
                    issue_id['breaking'] += 1
        
        # Check for new endpoints
        for path in head_paths:
            if path not in base_paths:
                non_breaking.append({
                    "id": f"OAC-{100 + issue_id['nonbreaking']:03d}",
                    "type": "endpoint_added",
                    "details": f"New endpoint {path} added"
                })
                issue_id['nonbreaking'] += 1
        
        # Check for schemas
        base_schemas = base_spec.get("components", {}).get("schemas", {})
        head_schemas = head_spec.get("components", {}).get("schemas", {})
        
        for schema_name, base_schema in base_schemas.items():
            if schema_name not in head_schemas:
                breaking.append({
                    "id": f"OAC-{issue_id['breaking']:03d}",
                    "type": "schema_removed",
                    "path": "",
                    "method": "",
                    "location": f"components.schemas.{schema_name}",
                    "why_it_breaks": f"Schema {schema_name} was removed",
                    "suggested_fix": "Deprecate schema instead of removing"
                })
                issue_id['breaking'] += 1
                continue
            
            head_schema = head_schemas[schema_name]
            base_props = base_schema.get("properties", {})
            head_props = head_schema.get("properties", {})
            
            # Check for required fields added
            base_required = base_schema.get("required", [])
            head_required = head_schema.get("required", [])
            
            for field in head_required:
                if field not in base_required:
                    breaking.append({
                        "id": f"OAC-{issue_id['breaking']:03d}",
                        "type": "required_added",
                        "path": "",
                        "method": "",
                        "location": f"components.schemas.{schema_name}.required",
                        "why_it_breaks": f"Required field '{field}' was added to schema {schema_name}",
                        "suggested_fix": "Add field with default value or make optional"
                    })
                    issue_id['breaking'] += 1
            
            # Check for field types changed
            for field, field_def in head_props.items():
                if field in base_props:
                    base_type = base_props[field].get("type")
                    head_type = field_def.get("type")
                    if base_type != head_type:
                        breaking.append({
                            "id": f"OAC-{issue_id['breaking']:03d}",
                            "type": "schema_incompatible",
                            "path": "",
                            "method": "",
                            "location": f"components.schemas.{schema_name}.{field}",
                            "why_it_breaks": f"Field '{field}' type changed from {base_type} to {head_type}",
                            "suggested_fix": "Use compatible type or version the schema"
                        })
                        issue_id['breaking'] += 1
            
            # Check for enum narrowing
            if "enum" in base_props:
                base_enum = set(base_props.get("enum", []))
                head_enum = set(head_props.get("enum", []))
                if head_enum < base_enum:  # Proper subset
                    breaking.append({
                        "id": f"OAC-{issue_id['breaking']:03d}",
                        "type": "enum_narrowed",
                        "path": "",
                        "method": "",
                        "location": f"components.schemas.{schema_name}.{field}",
                        "why_it_breaks": f"Enum values narrowed: {head_enum} vs {base_enum}",
                        "suggested_fix": "Add deprecated values as optional"
                    })
                    issue_id['breaking'] += 1
        
        # Generate summary
        if breaking:
            summary = f"🚫 {len(breaking)} breaking change(s) detected"
        elif non_breaking:
            summary = f"✅ {len(non_breaking)} non-breaking change(s), no breaking changes"
        else:
            summary = "✅ No changes detected"
        
        # Release checklist
        release_checklist = []
        if breaking:
            release_checklist.append({"item": "⚠️ Breaking changes detected - requires version bump"})
            release_checklist.append({"item": "Communicate changes to API consumers"})
        else:
            release_checklist.append({"item": "✅ No breaking changes - safe to release"})
        
        release_checklist.extend([
            {"item": "Update API documentation"},
            {"item": "Update client SDKs if applicable"},
            {"item": "Test with existing clients"}
        ])
        
        # Check fail_on_breaking
        if fail_on_breaking and breaking:
            return ToolResult(
                success=False,
                result={
                    "summary": summary,
                    "breaking": breaking,
                    "non_breaking": non_breaking,
                    "compat_score": {
                        "breaking_count": len(breaking),
                        "warnings": len(non_breaking),
                        "coverage": 0
                    },
                    "release_checklist": release_checklist
                },
                error=f"Breaking changes detected: {len(breaking)}"
            )
        
        result = {
            "summary": summary,
            "breaking": breaking,
            "non_breaking": non_breaking,
            "lint": [],
            "compat_score": {
                "breaking_count": len(breaking),
                "warnings": len(non_breaking),
                "coverage": max(0, 100 - len(breaking) * 25)
            },
            "release_checklist": release_checklist
        }
        
        return ToolResult(success=True, result=result)
    
    async def _generate_client_stub(self, inputs: Dict, options: Dict, format_type: str) -> ToolResult:
        """Generate basic client stub (MVP - Python)"""
        import yaml
        import json
        
        spec_value = inputs.get("base", {}).get("value") or inputs.get("head", {}).get("value", "")
        
        if not spec_value:
            return ToolResult(success=False, result=None, error="No spec provided")
        
        # Parse spec
        try:
            spec = yaml.safe_load(spec_value) if "yaml" in format_type else json.loads(spec_value)
        except Exception as e:
            return ToolResult(success=False, result=None, error=f"Failed to parse spec: {str(e)}")
        
        # Get API info
        info = spec.get("info", {})
        title = info.get("title", "API Client")
        version = info.get("version", "1.0.0")
        
        # Generate Python stub
        stub = f'''"""Auto-generated API Client for {title} v{version}"""

import requests
from typing import Optional, Dict, Any

class {title.replace(" ", "").replace("-", "")}Client:
    """Client for {title}"""
    
    def __init__(self, base_url: str, api_key: Optional[str] = None):
        self.base_url = base_url.rstrip("/")
        self.api_key = api_key
        self.session = requests.Session()
        if api_key:
            self.session.headers["Authorization"] = f"Bearer {{api_key}}"
    
'''
        
        paths = spec.get("paths", {})
        for path, methods in paths.items():
            if not isinstance(methods, dict):
                continue
            
            for method, details in methods.items():
                if method not in ["get", "post", "put", "delete", "patch"]:
                    continue
                
                operation_id = details.get("operationId", f"{method}_{path.replace('/', '_')}")
                summary = details.get("summary", "")
                
                # Generate method signature
                params = []
                for param in details.get("parameters", []):
                    param_name = param.get("name", "param")
                    param_required = param.get("required", False)
                    param_type = param.get("schema", {}).get("type", "str")
                    
                    if not param_required:
                        params.append(f"{param_name}: Optional[{param_type}] = None")
                    else:
                        params.append(f"{param_name}: {param_type}")
                
                params_str = ", ".join(params) if params else ""
                
                # Generate method docstring
                description = details.get("description", summary or f"{method.upper()} {path}")
                
                stub += f'''    def {operation_id}(self, {params_str}) -> Dict[str, Any]:
        """{description}
        
        Endpoint: {method.upper()} {path}
        """
        url = f"{{self.base_url}}{path}"
'''
                if method in ["post", "put", "patch"]:
                    stub += '''        response = self.session.request("''' + method.upper() + '''", url, json=data)
'''
                else:
                    stub += '''        response = self.session.request("''' + method.upper() + '''", url, params=params)
'''
                
                stub += '''        response.raise_for_status()
        return response.json()


'''
        
        result = {
            "summary": f"Generated Python client stub for {title}",
            "language": "python",
            "client_stub": stub,
            "info": {
                "title": title,
                "version": version,
                "endpoints": sum(len([m for m in methods if m in ["get", "post", "put", "delete", "patch"]]) for methods in paths.values() if isinstance(methods, dict))
            }
        }
        
        return ToolResult(success=True, result=result)
    
    async def _oncall_tool(self, args: Dict, agent_id: str = None) -> ToolResult:
        """
        Oncall/Runbook Tool - Operational information.
        
        Actions:
        - services_list: List all services
        - service_status: Get service status
        - service_health: Check health endpoint
        - deployments_recent: Recent deployments
        - runbook_search: Search runbooks
        - runbook_read: Read runbook
        - incident_log_list: List incidents
        - incident_log_append: Add incident (gated)
        
        Security:
        - Read-only except incident_log_append (gated)
        - Health checks only to allowlisted endpoints
        - Mask secrets in runbook content
        """
        import re
        import os
        import json
        import glob
        from datetime import datetime, timedelta
        from pathlib import Path
        
        action = (args or {}).get("action")
        params = (args or {}).get("params", {})
        
        repo_root = os.environ.get("REPO_ROOT", os.getcwd())
        
        # RBAC: Check if agent can write incidents
        can_write_incident = agent_id in ["sofiia", "helion", "admin"] if agent_id else False
        
        # Allowlist directories for runbooks
        RUNBOOK_DIRS = ["ops", "runbooks", "docs/runbooks", "docs/ops"]
        
        # Allowlist for health endpoints (internal only)
        HEALTH_ALLOWLIST = [
            "localhost",
            "127.0.0.1",
            "router-service",
            "gateway-service", 
            "memory-service",
            "swapper-service",
            "crewai-service"
        ]
        
        def mask_secrets(content: str) -> str:
            """Mask secrets in content"""
            patterns = [
                (re.compile(r'(?i)(api[_-]?key|token|secret|password)\s*=\s*.+'), r'\1=***'),
            ]
            for pattern, replacement in patterns:
                content = pattern.sub(replacement, content)
            return content
        
        try:
            if action == "services_list":
                # Parse docker-compose files to get services
                services = []
                
                # Look for docker-compose files
                compose_files = glob.glob(os.path.join(repo_root, "docker-compose*.yml"))
                compose_files += glob.glob(os.path.join(repo_root, "*.yml"))
                
                for compose_file in compose_files[:5]:  # Limit to 5 files
                    try:
                        with open(compose_file, 'r') as f:
                            content = f.read()
                            # Simple YAML parsing for service names
                            if "services:" in content:
                                lines = content.split("\n")
                                for i, line in enumerate(lines):
                                    if line.strip().startswith("services:"):
                                        # Next lines are service names (indented)
                                        for j in range(i+1, min(i+50, len(lines))):
                                            svc_line = lines[j].strip()
                                            if svc_line and not svc_line.startswith("#"):
                                                if svc_line.endswith(":"):
                                                    svc_name = svc_line.rstrip(":").strip()
                                                    if svc_name and not svc_name.startswith("-"):
                                                        services.append({
                                                            "name": svc_name,
                                                            "source": os.path.basename(compose_file),
                                                            "type": "service",
                                                            "criticality": "medium"
                                                        })
                    except:
                        pass
                
                # Also check for service catalog
                catalog_files = glob.glob(os.path.join(repo_root, "*SERVICE_CATALOG*.md"))
                if catalog_files:
                    try:
                        with open(catalog_files[0], 'r') as f:
                            content = f.read()
                            # Look for service definitions
                            for line in content.split("\n"):
                                if "|" in line and "name" in line.lower():
                                    parts = [p.strip() for p in line.split("|")]
                                    if len(parts) > 2 and parts[1]:
                                        services.append({
                                            "name": parts[1],
                                            "source": "catalog",
                                            "type": "service",
                                            "criticality": "medium"
                                        })
                    except:
                        pass
                
                return ToolResult(success=True, result={
                    "services": services[:50],  # Limit to 50
                    "count": len(services)
                })
            
            elif action == "service_health":
                service_name = params.get("service_name")
                health_endpoint = params.get("health_endpoint")
                
                if not health_endpoint:
                    # Construct default health endpoint
                    health_endpoint = f"http://{service_name}:8000/health" if service_name else None
                
                if not health_endpoint:
                    return ToolResult(success=False, result=None, error="No health endpoint specified")
                
                # Validate endpoint is allowlisted
                allowed = False
                for host in HEALTH_ALLOWLIST:
                    if host in health_endpoint:
                        allowed = True
                        break
                
                if not allowed:
                    return ToolResult(success=False, result=None, error=f"Health endpoint not in allowlist: {HEALTH_ALLOWLIST}")
                
                # Make health check request
                try:
                    response = await self.http_client.get(health_endpoint, timeout=3.0)
                    return ToolResult(success=True, result={
                        "service": service_name,
                        "endpoint": health_endpoint,
                        "status": "healthy" if response.status_code < 400 else "unhealthy",
                        "status_code": response.status_code,
                        "latency_ms": int(response.elapsed.total_seconds() * 1000)
                    })
                except Exception as e:
                    return ToolResult(success=True, result={
                        "service": service_name,
                        "endpoint": health_endpoint,
                        "status": "unreachable",
                        "error": str(e)
                    })
            
            elif action == "service_status":
                service_name = params.get("service_name")
                
                # Try to get version from various sources
                version_info = {
                    "service": service_name,
                    "version": None,
                    "last_seen": None
                }
                
                return ToolResult(success=True, result=version_info)
            
            elif action == "deployments_recent":
                # Try to read from ops/deployments.jsonl
                deploy_file = os.path.join(repo_root, "ops", "deployments.jsonl")
                deployments = []
                
                if os.path.exists(deploy_file):
                    try:
                        with open(deploy_file, 'r') as f:
                            for line in f:
                                if line.strip():
                                    try:
                                        deployments.append(json.loads(line))
                                    except:
                                        pass
                    except:
                        pass
                
                # If no deployments file, use git as fallback
                if not deployments:
                    try:
                        import subprocess
                        result = subprocess.run(
                            ["git", "log", "--oneline", "-10"],
                            cwd=repo_root,
                            capture_output=True,
                            text=True,
                            timeout=5
                        )
                        if result.returncode == 0:
                            for line in result.stdout.strip().split("\n"):
                                if line:
                                    deployments.append({
                                        "type": "git_commit",
                                        "commit": line
                                    })
                    except:
                        pass
                
                return ToolResult(success=True, result={
                    "deployments": deployments[:20],
                    "count": len(deployments)
                })
            
            elif action == "runbook_search":
                query = params.get("query", "")
                results = []
                
                # Search in allowed directories
                for runbook_dir in RUNBOOK_DIRS:
                    full_dir = os.path.join(repo_root, runbook_dir)
                    if os.path.exists(full_dir):
                        try:
                            for root, dirs, files in os.walk(full_dir):
                                # Skip hidden dirs
                                dirs[:] = [d for d in dirs if not d.startswith('.')]
                                
                                for file in files:
                                    if file.endswith(('.md', '.yml', '.yaml')):
                                        file_path = os.path.join(root, file)
                                        try:
                                            with open(file_path, 'r', errors='ignore') as f:
                                                content = f.read().lower()
                                                if query.lower() in content:
                                                    rel_path = os.path.relpath(file_path, repo_root)
                                                    results.append({
                                                        "path": rel_path,
                                                        "file": file
                                                    })
                                        except:
                                            pass
                                if len(results) >= 50:
                                    break
                        except:
                            pass
                
                return ToolResult(success=True, result={
                    "results": results[:20],
                    "query": query,
                    "count": len(results)
                })
            
            elif action == "runbook_read":
                runbook_path = params.get("runbook_path")
                
                if not runbook_path:
                    return ToolResult(success=False, result=None, error="runbook_path required")
                
                # Security: ensure path is within allowed directories
                allowed = False
                full_path = None
                
                for runbook_dir in RUNBOOK_DIRS:
                    base_dir = os.path.join(repo_root, runbook_dir)
                    if os.path.exists(base_dir):
                        if runbook_path.startswith(runbook_dir) or runbook_path.startswith(runbook_dir.replace("docs/", "")):
                            full_path = os.path.join(repo_root, runbook_path)
                            allowed = True
                            break
                
                # Also check direct path
                if not allowed:
                    full_path = os.path.join(repo_root, runbook_path)
                    for runbook_dir in RUNBOOK_DIRS:
                        if os.path.commonpath([full_path, os.path.join(repo_root, runbook_dir)]) == os.path.join(repo_root, runbook_dir):
                            allowed = True
                            break
                
                if not allowed:
                    return ToolResult(success=False, result=None, error="Runbook must be in allowed directories")
                
                # Check for path traversal
                real_path = os.path.realpath(full_path)
                if not real_path.startswith(os.path.realpath(repo_root)):
                    return ToolResult(success=False, result=None, error="Path traversal detected")
                
                # Check file exists
                if not os.path.exists(real_path):
                    return ToolResult(success=False, result=None, error="Runbook not found")
                
                # Read file
                try:
                    with open(real_path, 'r', encoding='utf-8', errors='ignore') as f:
                        content = f.read()
                    
                    # Limit size
                    if len(content) > 200 * 1024:
                        content = content[:200 * 1024] + "\n\n... [truncated]"
                    
                    # Mask secrets
                    content = mask_secrets(content)
                    
                    return ToolResult(success=True, result={
                        "path": runbook_path,
                        "content": content,
                        "size": len(content)
                    })
                except Exception as e:
                    return ToolResult(success=False, result=None, error=f"Error reading runbook: {str(e)}")
            
            # ── Incident CRUD (via incident_store) ────────────────────────
            elif action == "incident_create":
                if not can_write_incident:
                    return ToolResult(success=False, result=None,
                                     error="incident_create requires tools.oncall.incident_write")
                svc = params.get("service") or params.get("service_name")
                if not svc:
                    return ToolResult(success=False, result=None, error="service is required")
                try:
                    from incident_store import get_incident_store
                    store = get_incident_store()
                    inc = store.create_incident({
                        "workspace_id": params.get("workspace_id", "default"),
                        "service": svc,
                        "env": params.get("env", "prod"),
                        "severity": params.get("severity", "P2"),
                        "title": params.get("title", ""),
                        "summary": params.get("summary", ""),
                        "started_at": params.get("started_at", ""),
                        "created_by": agent_id or "unknown",
                    })
                    return ToolResult(success=True, result={
                        "incident_id": inc["id"], "status": inc["status"]
                    })
                except Exception as e:
                    return ToolResult(success=False, result=None, error=f"incident_create failed: {e}")

            elif action == "incident_get":
                inc_id = params.get("incident_id")
                if not inc_id:
                    return ToolResult(success=False, result=None, error="incident_id required")
                try:
                    from incident_store import get_incident_store
                    inc = get_incident_store().get_incident(inc_id)
                    if not inc:
                        return ToolResult(success=False, result=None, error=f"Incident {inc_id} not found")
                    return ToolResult(success=True, result=inc)
                except Exception as e:
                    return ToolResult(success=False, result=None, error=f"incident_get failed: {e}")

            elif action == "incident_list":
                try:
                    from incident_store import get_incident_store
                    filters = {}
                    for k in ("status", "service", "env", "severity"):
                        v = params.get(k)
                        if v:
                            filters[k] = v
                    limit_val = min(int(params.get("limit", 50)), 200)
                    incidents = get_incident_store().list_incidents(filters, limit=limit_val)
                    return ToolResult(success=True, result={
                        "incidents": incidents, "count": len(incidents)
                    })
                except Exception as e:
                    return ToolResult(success=False, result=None, error=f"incident_list failed: {e}")

            elif action == "incident_close":
                if not can_write_incident:
                    return ToolResult(success=False, result=None,
                                     error="incident_close requires tools.oncall.incident_write")
                inc_id = params.get("incident_id")
                if not inc_id:
                    return ToolResult(success=False, result=None, error="incident_id required")
                try:
                    from incident_store import get_incident_store
                    inc = get_incident_store().close_incident(
                        inc_id,
                        ended_at=params.get("ended_at", ""),
                        resolution=params.get("resolution_summary", ""),
                    )
                    if not inc:
                        return ToolResult(success=False, result=None, error=f"Incident {inc_id} not found")
                    return ToolResult(success=True, result={
                        "incident_id": inc_id, "status": "closed"
                    })
                except Exception as e:
                    return ToolResult(success=False, result=None, error=f"incident_close failed: {e}")

            elif action == "incident_append_event":
                if not can_write_incident:
                    return ToolResult(success=False, result=None,
                                     error="incident_append_event requires tools.oncall.incident_write")
                inc_id = params.get("incident_id")
                if not inc_id:
                    return ToolResult(success=False, result=None, error="incident_id required")
                ev_type = params.get("type", "note")
                msg = params.get("message", "")
                meta = params.get("meta")
                try:
                    from incident_store import get_incident_store
                    ev = get_incident_store().append_event(inc_id, ev_type, msg, meta)
                    if not ev:
                        return ToolResult(success=False, result=None, error=f"Incident {inc_id} not found")
                    return ToolResult(success=True, result={"event": ev})
                except Exception as e:
                    return ToolResult(success=False, result=None, error=f"incident_append_event failed: {e}")

            elif action == "incident_followups_summary":
                try:
                    from datetime import datetime as _dt, timedelta as _td
                    from incident_store import get_incident_store
                    store = get_incident_store()
                    svc = params.get("service") or params.get("service_name") or ""
                    env = params.get("env", "any")
                    window_days = min(int(params.get("window_days", 30)), 365)
                    limit_val = min(int(params.get("limit", 100)), 500)

                    filters: Dict = {}
                    if svc:
                        filters["service"] = svc
                    if env and env != "any":
                        filters["env"] = env
                    incidents = store.list_incidents(filters, limit=limit_val)

                    now_dt = _dt.utcnow()
                    if window_days > 0:
                        cutoff = now_dt - _td(days=window_days)
                        incidents = [i for i in incidents
                                     if i.get("created_at", "") >= cutoff.isoformat()]

                    open_critical = [
                        {"id": i["id"], "severity": i.get("severity"), "status": i.get("status"),
                         "started_at": i.get("started_at"), "title": i.get("title", "")[:200]}
                        for i in incidents
                        if i.get("status") in ("open", "mitigating", "resolved")
                        and i.get("severity") in ("P0", "P1")
                    ]

                    overdue = []
                    for inc in incidents:
                        events = store.get_events(inc["id"], limit=200)
                        for ev in events:
                            if ev.get("type") != "followup":
                                continue
                            meta = ev.get("meta") or {}
                            if isinstance(meta, str):
                                try:
                                    meta = json.loads(meta)
                                except Exception:
                                    meta = {}
                            if meta.get("status", "open") != "open":
                                continue
                            due = meta.get("due_date", "")
                            if due and due < now_dt.isoformat():
                                overdue.append({
                                    "incident_id": inc["id"],
                                    "title": meta.get("title", ev.get("message", "")[:200]),
                                    "due_date": due,
                                    "priority": meta.get("priority", "P2"),
                                    "owner": meta.get("owner", ""),
                                })

                    total_open = sum(
                        1 for inc in incidents
                        for ev in store.get_events(inc["id"], limit=200)
                        if ev.get("type") == "followup"
                        and (ev.get("meta") or {}).get("status", "open") == "open"
                    )

                    return ToolResult(success=True, result={
                        "open_incidents": open_critical[:20],
                        "overdue_followups": overdue[:30],
                        "stats": {
                            "open_incidents": len(open_critical),
                            "overdue": len(overdue),
                            "total_open_followups": total_open,
                        },
                    })
                except Exception as e:
                    return ToolResult(success=False, result=None,
                                     error=f"incident_followups_summary failed: {e}")

            elif action == "incident_attach_artifact":
                if not can_write_incident:
                    return ToolResult(success=False, result=None,
                                     error="incident_attach_artifact requires tools.oncall.incident_write")
                inc_id = params.get("incident_id")
                if not inc_id:
                    return ToolResult(success=False, result=None, error="incident_id required")
                content_b64 = params.get("content_base64", "")
                if not content_b64:
                    return ToolResult(success=False, result=None, error="content_base64 required")
                kind = params.get("kind", "attachment")
                fmt = params.get("format", "json")
                fname = params.get("filename") or f"{kind}.{fmt}"
                try:
                    from incident_artifacts import write_artifact, decode_content
                    content_bytes = decode_content(content_b64)
                    art_info = write_artifact(inc_id, fname, content_bytes)

                    from incident_store import get_incident_store
                    get_incident_store().add_artifact(
                        inc_id, kind, fmt,
                        art_info["path"], art_info["sha256"], art_info["size_bytes"],
                    )
                    return ToolResult(success=True, result={"artifact": art_info})
                except ValueError as ve:
                    return ToolResult(success=False, result=None, error=str(ve))
                except Exception as e:
                    return ToolResult(success=False, result=None, error=f"incident_attach_artifact failed: {e}")

            elif action in ("signature_should_triage", "signature_mark_triage", "signature_mark_alert"):
                try:
                    from signature_state_store import get_signature_state_store
                    sig_store = get_signature_state_store()
                    signature = params.get("signature", "")
                    if not signature:
                        return ToolResult(success=False, result=None, error="signature required")

                    if action == "signature_should_triage":
                        cooldown = int(params.get("cooldown_minutes", 15))
                        result_val = sig_store.should_run_triage(signature, cooldown)
                        state = sig_store.get_state(signature)
                        return ToolResult(success=True, result={
                            "should_triage": result_val,
                            "signature": signature,
                            "state": state,
                        })
                    elif action == "signature_mark_triage":
                        sig_store.mark_triage_run(signature)
                        return ToolResult(success=True, result={
                            "signature": signature, "marked": "triage_run"
                        })
                    else:  # signature_mark_alert
                        sig_store.mark_alert_seen(signature)
                        return ToolResult(success=True, result={
                            "signature": signature, "marked": "alert_seen"
                        })
                except Exception as e:
                    return ToolResult(success=False, result=None,
                                     error=f"signature action failed: {e}")

            elif action == "alert_to_incident":
                if not can_write_incident:
                    return ToolResult(success=False, result=None,
                                     error="alert_to_incident requires tools.oncall.incident_write")
                try:
                    from datetime import datetime as _dt, timedelta as _td
                    from alert_store import get_alert_store
                    from alert_ingest import map_alert_severity_to_incident
                    from incident_store import get_incident_store
                    from incident_artifacts import write_artifact

                    alert_ref = params.get("alert_ref")
                    if not alert_ref:
                        return ToolResult(success=False, result=None, error="alert_ref required")

                    astore = get_alert_store()
                    alert = astore.get_alert(alert_ref)
                    if not alert:
                        return ToolResult(success=False, result=None,
                                         error=f"Alert {alert_ref} not found")

                    sev_cap = params.get("incident_severity_cap", "P1")
                    dedupe_win = int(params.get("dedupe_window_minutes", 60))

                    istore = get_incident_store()
                    service = alert.get("service", "unknown")
                    env = alert.get("env", "prod")
                    kind = alert.get("kind", "custom")

                    # Compute incident signature for precise deduplication
                    import hashlib as _hl
                    labels = alert.get("labels", {})
                    fp = labels.get("fingerprint", "")
                    sig_raw = f"{service}|{env}|{kind}|{fp}"
                    incident_signature = _hl.sha256(sig_raw.encode()).hexdigest()[:32]

                    # Signature-based dedupe: reuse only if same signature
                    cutoff = (_dt.utcnow() - _td(minutes=dedupe_win)).isoformat()
                    existing_incs = istore.list_incidents(
                        {"service": service, "env": env}, limit=20
                    )

                    # Find open incident with matching signature (stored in meta)
                    open_inc = None
                    for i in existing_incs:
                        if i.get("status") not in ("open", "mitigating"):
                            continue
                        if i.get("severity") not in ("P0", "P1"):
                            continue
                        if i.get("started_at", "") < cutoff:
                            continue
                        # Check signature stored in incident meta
                        inc_sig = (i.get("meta") or {}).get("incident_signature", "")
                        if inc_sig and inc_sig == incident_signature:
                            open_inc = i
                            break

                    if open_inc:
                        incident_id = open_inc["id"]
                        ev = istore.append_event(
                            incident_id, "note",
                            f"Alert re-triggered: {alert.get('title', '')}",
                            meta={"alert_ref": alert_ref,
                                  "occurrences": alert.get("occurrences", 1),
                                  "incident_signature": incident_signature},
                        )
                        astore.ack_alert(alert_ref, args.get("agent_id", "oncall_tool"),
                                         note=f"incident:{incident_id}")
                        return ToolResult(success=True, result={
                            "incident_id": incident_id,
                            "created": False,
                            "attached_event_id": None,
                            "incident_signature": incident_signature,
                            "note": "Attached to existing open incident (signature match)",
                        })

                    # Create new incident
                    sev = map_alert_severity_to_incident(
                        alert.get("severity", "P2"), sev_cap
                    )
                    inc = istore.create_incident({
                        "service": service,
                        "env": env,
                        "severity": sev,
                        "title": alert.get("title", "Alert triggered"),
                        "summary": alert.get("summary", ""),
                        "started_at": alert.get("started_at") or _dt.utcnow().isoformat(),
                        "created_by": args.get("agent_id", "oncall_tool"),
                        "meta": {"incident_signature": incident_signature,
                                 "alert_ref": alert_ref,
                                 "alert_kind": kind,
                                 "alert_source": alert.get("source", "")},
                    })
                    incident_id = inc["id"]

                    # Append timeline events
                    istore.append_event(incident_id, "note",
                                        f"Created from alert {alert_ref}: {alert.get('title', '')}",
                                        meta={"alert_ref": alert_ref,
                                              "incident_signature": incident_signature,
                                              "source": alert.get("source", "")})
                    if alert.get("metrics"):
                        istore.append_event(incident_id, "metric",
                                            "Alert metrics at detection",
                                            meta=alert["metrics"])

                    # Attach artifact (masked alert JSON)
                    artifact_path = ""
                    if params.get("attach_artifact", True):
                        import base64 as _b64
                        safe_alert = {
                            k: v for k, v in alert.items()
                            if k not in ("evidence",)
                        }
                        safe_alert["evidence"] = {
                            "log_samples": [],
                            "note": "evidence redacted for artifact storage",
                        }
                        content = json.dumps(safe_alert, indent=2, default=str).encode()
                        art = write_artifact(
                            incident_id,
                            f"alert_{alert_ref}.json",
                            content,
                        )
                        artifact_path = art.get("path", "")
                        istore.add_artifact(
                            incident_id, "alert", "json",
                            artifact_path, art.get("sha256", ""), art.get("size_bytes", 0)
                        )

                    astore.ack_alert(alert_ref, args.get("agent_id", "oncall_tool"),
                                     note=f"incident:{incident_id}")

                    return ToolResult(success=True, result={
                        "incident_id": incident_id,
                        "created": True,
                        "severity": sev,
                        "incident_signature": incident_signature,
                        "artifact_path": artifact_path,
                        "note": "Incident created and alert acked",
                    })

                except Exception as e:
                    return ToolResult(success=False, result=None,
                                     error=f"alert_to_incident failed: {e}")

            else:
                return ToolResult(
                    success=False,
                    result=None,
                    error=f"Unknown action: {action}"
                )
                
        except Exception as e:
            logger.error(f"Oncall tool error: {e}")
            return ToolResult(success=False, result=None, error=f"Internal error: {str(e)}")

    async def _alert_ingest_tool(self, args: Dict) -> ToolResult:
        """
        Alert Ingest Tool — alert dedup + state machine lifecycle.

        Actions:
          ingest — accept alert with dedupe (Monitor role: tools.alerts.ingest)
          list   — list recent alerts by status (read: tools.alerts.read)
          get    — get full alert (read)
          ack    — mark acked (tools.alerts.ack)
          claim  — atomic claim batch for processing (tools.alerts.claim)
          fail   — mark processing failed, schedule retry (tools.alerts.ack)
        """
        action = (args or {}).get("action")
        params = (args or {}).get("params", args or {})

        from alert_store import get_alert_store
        from alert_ingest import (
            ingest_alert, list_alerts, get_alert, ack_alert,
        )
        store = get_alert_store()

        try:
            if action == "ingest":
                alert_data = params.get("alert") or params
                dedupe_ttl = int(params.get("dedupe_ttl_minutes", 30))
                result = ingest_alert(store, alert_data, dedupe_ttl_minutes=dedupe_ttl)
                if not result.get("accepted", True):
                    return ToolResult(success=False, result=None,
                                     error=result.get("error", "Validation failed"))
                return ToolResult(success=True, result=result)

            elif action == "list":
                service = params.get("service")
                env = params.get("env")
                window = int(params.get("window_minutes", 240))
                limit = min(int(params.get("limit", 50)), 200)
                # Default to new+failed for operational list; pass explicit status_in if needed
                filters = {}
                if service:
                    filters["service"] = service
                if env:
                    filters["env"] = env
                filters["window_minutes"] = window
                status_in = params.get("status_in")
                if status_in:
                    filters["status_in"] = status_in
                results = store.list_alerts(filters, limit=limit)
                return ToolResult(success=True, result={"alerts": results, "count": len(results)})

            elif action == "get":
                alert_ref = params.get("alert_ref")
                if not alert_ref:
                    return ToolResult(success=False, result=None, error="alert_ref required")
                rec = get_alert(store, alert_ref)
                if rec is None:
                    return ToolResult(success=False, result=None,
                                     error=f"Alert {alert_ref} not found")
                return ToolResult(success=True, result=rec)

            elif action == "ack":
                alert_ref = params.get("alert_ref")
                if not alert_ref:
                    return ToolResult(success=False, result=None, error="alert_ref required")
                actor = params.get("actor") or args.get("agent_id", "unknown")
                note = params.get("note", "")
                result = store.mark_acked(alert_ref, actor, note)
                if result is None:
                    return ToolResult(success=False, result=None,
                                     error=f"Alert {alert_ref} not found")
                return ToolResult(success=True, result=result)

            elif action == "claim":
                window = int(params.get("window_minutes", 240))
                limit = min(int(params.get("limit", 25)), 50)
                owner = params.get("owner") or args.get("agent_id", "loop")
                lock_ttl = int(params.get("lock_ttl_seconds", 600))
                # Requeue any stale processing first (maintenance)
                requeued = store.requeue_expired_processing()
                if requeued > 0:
                    logger.info("alert_ingest_tool.claim: requeued %d stale processing alerts", requeued)
                claimed = store.claim_next_alerts(
                    window_minutes=window, limit=limit,
                    owner=owner, lock_ttl_seconds=lock_ttl
                )
                return ToolResult(success=True, result={
                    "alerts": claimed,
                    "claimed": len(claimed),
                    "requeued_stale": requeued,
                })

            elif action == "fail":
                alert_ref = params.get("alert_ref")
                if not alert_ref:
                    return ToolResult(success=False, result=None, error="alert_ref required")
                error_msg = params.get("error", "processing failed")
                retry_after = int(params.get("retry_after_seconds", 300))
                result = store.mark_failed(alert_ref, error_msg, retry_after)
                if result is None:
                    return ToolResult(success=False, result=None,
                                     error=f"Alert {alert_ref} not found")
                return ToolResult(success=True, result=result)

            else:
                return ToolResult(success=False, result=None,
                                 error=f"Unknown action: {action}")
        except Exception as e:
            logger.error("alert_ingest_tool error: %s", e)
            return ToolResult(success=False, result=None, error=f"Internal error: {e}")

    async def _incident_escalation_tool(self, args: Dict) -> ToolResult:
        """
        Incident Escalation Engine (deterministic, 0 LLM tokens).

        evaluate              — escalate open incidents based on occurrences/triage thresholds
        auto_resolve_candidates — find open incidents where alerts have gone quiet
        """
        action = (args or {}).get("action")
        params = (args or {}).get("params", args or {})

        try:
            from incident_escalation import (
                evaluate_escalations, find_auto_resolve_candidates, load_escalation_policy
            )
            from alert_store import get_alert_store
            from signature_state_store import get_signature_state_store
            from incident_store import get_incident_store

            alert_store = get_alert_store()
            sig_store = get_signature_state_store()
            istore = get_incident_store()
            policy = load_escalation_policy()

            dry_run = bool(params.get("dry_run", False))

            if action == "evaluate":
                result = evaluate_escalations(
                    params=params,
                    alert_store=alert_store,
                    sig_state_store=sig_store,
                    incident_store=istore,
                    policy=policy,
                    dry_run=dry_run,
                )
                return ToolResult(success=True, result=result)

            elif action == "auto_resolve_candidates":
                result = find_auto_resolve_candidates(
                    params=params,
                    sig_state_store=sig_store,
                    incident_store=istore,
                    policy=policy,
                    dry_run=dry_run,
                )
                return ToolResult(success=True, result=result)

            else:
                return ToolResult(success=False, result=None,
                                 error=f"Unknown action: {action}")

        except Exception as e:
            logger.error("incident_escalation_tool error: %s", e)
            return ToolResult(success=False, result=None, error=f"Internal error: {e}")

    async def _incident_intelligence_tool(self, args: Dict) -> ToolResult:
        """
        Incident Intelligence Layer — deterministic, 0 LLM tokens.

        correlate       — find related incidents by scored matching
        recurrence      — frequency tables for a given window
        weekly_digest   — full weekly md+json report saved to artifacts
        """
        action = (args or {}).get("action")
        try:
            from incident_intelligence import (
                correlate_incident, detect_recurrence, weekly_digest,
                load_intel_policy,
            )
            from incident_store import get_incident_store

            istore = get_incident_store()
            policy = load_intel_policy()

            if action == "correlate":
                incident_id = (args or {}).get("incident_id") or ""
                if not incident_id:
                    return ToolResult(success=False, result=None,
                                      error="incident_id is required for correlate")
                append_note = bool((args or {}).get("append_note", False))
                related = correlate_incident(
                    incident_id=incident_id,
                    policy=policy,
                    store=istore,
                    append_note=append_note,
                )
                return ToolResult(success=True, result={
                    "incident_id": incident_id,
                    "related_count": len(related),
                    "related": related,
                })

            elif action == "recurrence":
                from incident_intelligence import recurrence_for_service
                window_days = int((args or {}).get("window_days", 7))
                service = (args or {}).get("service", "")
                if service:
                    stats = recurrence_for_service(
                        service=service,
                        window_days=window_days,
                        policy=policy,
                        store=istore,
                    )
                else:
                    stats = detect_recurrence(
                        window_days=window_days,
                        policy=policy,
                        store=istore,
                    )
                return ToolResult(success=True, result=stats)

            elif action == "buckets":
                from incident_intelligence import build_root_cause_buckets, _incidents_in_window
                window_days = int((args or {}).get("window_days", 30))
                incidents = _incidents_in_window(istore, window_days)
                service = (args or {}).get("service", "")
                if service:
                    incidents = [i for i in incidents if i.get("service", "") == service]
                result_buckets = build_root_cause_buckets(
                    incidents=incidents,
                    policy=policy,
                    windows=[7, window_days],
                )
                return ToolResult(success=True, result={
                    "service_filter": service or "all",
                    "window_days": window_days,
                    "bucket_count": len(result_buckets),
                    "buckets": result_buckets,
                })

            elif action == "weekly_digest":
                save_artifacts = bool((args or {}).get("save_artifacts", True))
                result = weekly_digest(
                    policy=policy,
                    store=istore,
                    save_artifacts=save_artifacts,
                )
                return ToolResult(success=True, result={
                    "week": result["week"],
                    "artifact_paths": result.get("artifact_paths", []),
                    "markdown_preview": result["markdown"][:1200],
                    "json_summary": {
                        k: v for k, v in result["json_data"].items()
                        if k in ("week", "generated_at", "open_incidents_count",
                                 "recent_7d_count", "recent_30d_count",
                                 "recommendations")
                    },
                })

            else:
                return ToolResult(success=False, result=None,
                                  error=f"Unknown action: {action}")

        except Exception as e:
            logger.error("incident_intelligence_tool error: %s", e)
            return ToolResult(success=False, result=None, error=f"Internal error: {e}")

    async def _risk_engine_tool(self, args: Dict, agent_id: str = "ops") -> ToolResult:
        """
        Risk Index Engine — deterministic, 0 LLM tokens.

        service   — compute RiskReport for a single service
        dashboard — compute top-N risky services
        policy    — return effective risk policy
        """
        action = (args or {}).get("action")
        try:
            from risk_engine import (
                load_risk_policy, compute_service_risk, compute_risk_dashboard,
                score_to_band,
            )
            from incident_store import get_incident_store
            from incident_intelligence import recurrence_for_service

            policy = load_risk_policy()

            if action == "policy":
                return ToolResult(success=True, result=policy)

            env = (args or {}).get("env", "prod")
            window_hours = int((args or {}).get("window_hours", 24))

            include_trend = (args or {}).get("include_trend", True)

            if action == "service":
                service = (args or {}).get("service", "")
                if not service:
                    return ToolResult(success=False, result=None,
                                      error="service is required for service action")
                report = await self._fetch_service_risk(
                    service, env, window_hours, policy, agent_id
                )
                if include_trend:
                    try:
                        from risk_engine import enrich_risk_report_with_trend
                        from risk_history_store import get_risk_history_store
                        enrich_risk_report_with_trend(report, get_risk_history_store(), policy)
                    except Exception as te:
                        logger.warning("risk_engine_tool trend enrich failed: %s", te)
                return ToolResult(success=True, result=report)

            elif action == "dashboard":
                top_n = int((args or {}).get("top_n", 10))
                services = await self._known_services()
                service_reports = []
                for svc in services[:20]:
                    try:
                        r = await self._fetch_service_risk(
                            svc, env, window_hours, policy, agent_id
                        )
                        service_reports.append(r)
                    except Exception as e:
                        logger.warning("risk_dashboard: skip %s: %s", svc, e)

                history_store = None
                if include_trend:
                    try:
                        from risk_history_store import get_risk_history_store
                        history_store = get_risk_history_store()
                    except Exception:
                        pass

                dashboard = compute_risk_dashboard(
                    env=env, top_n=top_n,
                    service_reports=service_reports,
                    history_store=history_store,
                    policy=policy,
                )
                return ToolResult(success=True, result=dashboard)

            else:
                return ToolResult(success=False, result=None,
                                  error=f"Unknown action: {action}")

        except Exception as e:
            logger.error("risk_engine_tool error: %s", e)
            return ToolResult(success=False, result=None, error=f"Internal error: {e}")

    async def _fetch_service_risk(
        self,
        service: str,
        env: str,
        window_hours: int,
        policy: Dict,
        agent_id: str,
    ) -> Dict:
        """Fetch all signal data and compute RiskReport for a single service."""
        from risk_engine import compute_service_risk
        from incident_store import get_incident_store
        from incident_intelligence import recurrence_for_service, load_intel_policy

        istore = get_incident_store()
        intel_policy = load_intel_policy()

        # ── Open incidents ────────────────────────────────────────────────────
        open_incs: List[Dict] = []
        try:
            filters = {"service": service, "env": env}
            open_incs = istore.list_incidents(filters, limit=50)
            open_incs = [i for i in open_incs if i.get("status") in ("open", "mitigating")]
        except Exception as e:
            logger.warning("risk fetch open_incidents failed: %s", e)

        # ── Recurrence ────────────────────────────────────────────────────────
        rec_7d: Dict = {}
        rec_30d: Dict = {}
        try:
            rec_7d = recurrence_for_service(service=service, window_days=7,
                                             policy=intel_policy, store=istore)
            rec_30d = recurrence_for_service(service=service, window_days=30,
                                              policy=intel_policy, store=istore)
        except Exception as e:
            logger.warning("risk fetch recurrence failed: %s", e)

        # ── Follow-ups ────────────────────────────────────────────────────────
        followups_data: Dict = {}
        try:
            fu_result = await self.execute_tool(
                "oncall_tool",
                {"action": "incident_followups_summary",
                 "service": service, "env": env, "window_days": 30},
                agent_id=agent_id,
            )
            if fu_result.success:
                followups_data = fu_result.result or {}
        except Exception as e:
            logger.warning("risk fetch followups failed: %s", e)

        # ── SLO ───────────────────────────────────────────────────────────────
        slo_data: Dict = {"violations": [], "skipped": True}
        try:
            slo_result = await self.execute_tool(
                "observability_tool",
                {"action": "slo_snapshot", "service": service, "env": env,
                 "window_minutes": int(policy.get("defaults", {}).get("slo_window_minutes", 60))},
                agent_id=agent_id,
            )
            if slo_result.success:
                slo_data = slo_result.result or {}
        except Exception as e:
            logger.warning("risk fetch slo_snapshot failed: %s", e)

        # ── Alert-loop SLO (global, not per-service) ──────────────────────────
        alerts_loop_slo: Dict = {}
        try:
            from alert_store import get_alert_store
            from incident_escalation_policy import load_escalation_policy  # noqa
        except ImportError:
            pass  # optional
        try:
            alert_store = None
            from alert_store import get_alert_store
            alert_store = get_alert_store()
            alerts_loop_slo = alert_store.compute_loop_slo() or {}
        except Exception as e:
            logger.warning("risk fetch alerts_loop_slo failed: %s", e)

        # ── Escalations (count from decision events in recent incidents) ──────
        escalation_count = 0
        try:
            cutoff = (datetime.datetime.utcnow() -
                      datetime.timedelta(hours=window_hours)).isoformat()
            service_incs = istore.list_incidents({"service": service}, limit=50)
            for inc in service_incs:
                events = istore.get_events(inc["id"], limit=50)
                for ev in events:
                    if (ev.get("type") == "decision"
                            and "Escalat" in (ev.get("message") or "")
                            and ev.get("ts", "") >= cutoff):
                        escalation_count += 1
        except Exception as e:
            logger.warning("risk fetch escalations failed: %s", e)

        return compute_service_risk(
            service=service,
            env=env,
            open_incidents=open_incs,
            recurrence_7d=rec_7d,
            recurrence_30d=rec_30d,
            followups_data=followups_data,
            slo_data=slo_data,
            alerts_loop_slo=alerts_loop_slo,
            escalation_count_24h=escalation_count,
            policy=policy,
        )

    async def _risk_history_tool(self, args: Dict, agent_id: str = "ops") -> ToolResult:
        """
        Risk History Tool — snapshot, cleanup, series, digest.
        RBAC: tools.risk.write (snapshot/cleanup/digest), tools.risk.read (series).
        """
        action = (args or {}).get("action")
        try:
            from risk_engine import load_risk_policy, snapshot_all_services, enrich_risk_report_with_trend
            from risk_history_store import get_risk_history_store
            from risk_digest import daily_digest

            policy = load_risk_policy()
            history_store = get_risk_history_store()
            env = (args or {}).get("env", "prod")

            if action == "snapshot":
                services = await self._known_services()
                max_s = int(policy.get("history", {}).get("max_services_per_run", 50))
                services = services[:max_s]

                def _sync_compute(svc: str, e: str):
                    import asyncio
                    loop = asyncio.new_event_loop()
                    try:
                        return loop.run_until_complete(
                            self._fetch_service_risk(svc, e, 24, policy, agent_id)
                        )
                    finally:
                        loop.close()

                result = snapshot_all_services(
                    env=env,
                    compute_fn=_sync_compute,
                    history_store=history_store,
                    policy=policy,
                    known_services=services,
                )
                return ToolResult(success=True, result=result)

            elif action == "cleanup":
                retention_days = int(
                    (args or {}).get("retention_days",
                                     policy.get("history", {}).get("retention_days", 90))
                )
                deleted = history_store.cleanup(retention_days)
                return ToolResult(success=True, result={"deleted": deleted,
                                                         "retention_days": retention_days})

            elif action == "series":
                service = (args or {}).get("service", "")
                if not service:
                    return ToolResult(success=False, result=None,
                                      error="service is required for series action")
                hours = int((args or {}).get("hours", 168))
                series = history_store.get_series(service, env, hours=hours, limit=200)
                return ToolResult(success=True, result={
                    "service": service, "env": env, "hours": hours,
                    "count": len(series),
                    "series": [s.to_dict() for s in series],
                })

            elif action == "digest":
                services = await self._known_services()
                service_reports = []
                for svc in services[:20]:
                    try:
                        r = await self._fetch_service_risk(svc, env, 24, policy, agent_id)
                        enrich_risk_report_with_trend(r, history_store, policy)
                        # Attribution (deterministic, non-fatal)
                        try:
                            from risk_engine import enrich_risk_report_with_attribution
                            from incident_store import get_incident_store
                            from alert_store import get_alert_store
                            enrich_risk_report_with_attribution(
                                r,
                                alert_store=get_alert_store(),
                                incident_store=get_incident_store(),
                            )
                        except Exception as ae:
                            logger.warning("digest attribution skip %s: %s", svc, ae)
                        service_reports.append(r)
                    except Exception as e:
                        logger.warning("risk_history digest: skip %s: %s", svc, e)
                result = daily_digest(
                    env=env,
                    service_reports=service_reports,
                    policy=policy,
                    write_files=True,
                )
                return ToolResult(success=True, result={
                    "date": result["date"],
                    "env": result["env"],
                    "json_path": result.get("json_path"),
                    "md_path": result.get("md_path"),
                    "markdown_preview": result["markdown"][:1500],
                    "band_counts": result["json_data"].get("band_counts"),
                    "top_regressions": result["json_data"].get("top_regressions"),
                    "actions_count": len(result["json_data"].get("actions", [])),
                })

            else:
                return ToolResult(success=False, result=None,
                                  error=f"Unknown action: {action}")

        except Exception as e:
            logger.error("risk_history_tool error: %s", e)
            return ToolResult(success=False, result=None, error=f"Internal error: {e}")

    async def _backlog_tool(self, args: Dict, agent_id: str = "ops") -> ToolResult:
        """
        Engineering Backlog Tool — list/get/dashboard/create/upsert/set_status/
        add_comment/close/auto_generate_weekly/cleanup.
        RBAC: tools.backlog.read/write/admin.
        """
        action = (args or {}).get("action", "")
        try:
            from backlog_store import (
                get_backlog_store, BacklogItem, BacklogEvent,
                validate_transition, _new_id, _now_iso,
            )
            from backlog_generator import load_backlog_policy

            store = get_backlog_store()
            policy = load_backlog_policy()
            env = (args or {}).get("env", "prod")

            # ── READ ──────────────────────────────────────────────────────────
            if action == "list":
                filters = {
                    k: (args or {}).get(k)
                    for k in ("env", "service", "status", "owner", "category", "due_before")
                    if (args or {}).get(k)
                }
                filters["env"] = filters.get("env") or env
                limit = int((args or {}).get("limit", 50))
                offset = int((args or {}).get("offset", 0))
                items = store.list_items(filters, limit=limit, offset=offset)
                return ToolResult(success=True, result={
                    "items": [it.to_dict() for it in items],
                    "count": len(items),
                    "filters": filters,
                })

            elif action == "get":
                item_id = (args or {}).get("id", "")
                if not item_id:
                    return ToolResult(success=False, result=None, error="id is required")
                item = store.get(item_id)
                if item is None:
                    return ToolResult(success=False, result=None, error=f"Item not found: {item_id}")
                events = store.get_events(item_id, limit=20)
                return ToolResult(success=True, result={
                    "item": item.to_dict(),
                    "events": [e.to_dict() for e in events],
                })

            elif action == "dashboard":
                return ToolResult(success=True, result=store.dashboard(env=env))

            # ── WRITE ─────────────────────────────────────────────────────────
            elif action in ("create", "upsert"):
                item_data = dict((args or {}).get("item") or {})
                item_data.setdefault("env", env)
                item_data.setdefault("source", "manual")
                if not item_data.get("id"):
                    item_data["id"] = _new_id("bl")
                item_data.setdefault("created_at", _now_iso())
                item_data.setdefault("updated_at", _now_iso())
                item = BacklogItem.from_dict(item_data)
                if action == "create":
                    created = store.create(item)
                    store.add_event(BacklogEvent(
                        id=_new_id("ev"), item_id=created.id, ts=_now_iso(),
                        type="created", message="Item created manually", actor=agent_id,
                    ))
                    return ToolResult(success=True, result={"action": "created", "item": created.to_dict()})
                else:
                    result = store.upsert(item)
                    return ToolResult(success=True, result={
                        "action": result["action"],
                        "item": result["item"].to_dict(),
                    })

            elif action == "set_status":
                item_id = (args or {}).get("id", "")
                new_status = (args or {}).get("status", "")
                message = (args or {}).get("message", "")
                if not item_id or not new_status:
                    return ToolResult(success=False, result=None,
                                      error="id and status are required")
                item = store.get(item_id)
                if item is None:
                    return ToolResult(success=False, result=None,
                                      error=f"Item not found: {item_id}")
                if not validate_transition(item.status, new_status, policy):
                    return ToolResult(success=False, result=None,
                                      error=f"Invalid transition: {item.status} → {new_status}")
                old_status = item.status
                item.status = new_status
                item.updated_at = _now_iso()
                store.update(item)
                store.add_event(BacklogEvent(
                    id=_new_id("ev"), item_id=item_id, ts=_now_iso(),
                    type="status_change",
                    message=message or f"Status: {old_status} → {new_status}",
                    actor=agent_id,
                    meta={"old_status": old_status, "new_status": new_status},
                ))
                return ToolResult(success=True, result={"item": item.to_dict()})

            elif action == "add_comment":
                item_id = (args or {}).get("id", "")
                message = (args or {}).get("message", "")
                if not item_id or not message:
                    return ToolResult(success=False, result=None,
                                      error="id and message are required")
                item = store.get(item_id)
                if item is None:
                    return ToolResult(success=False, result=None,
                                      error=f"Item not found: {item_id}")
                ev = store.add_event(BacklogEvent(
                    id=_new_id("ev"), item_id=item_id, ts=_now_iso(),
                    type="comment", message=message, actor=agent_id,
                ))
                return ToolResult(success=True, result={"event": ev.to_dict()})

            elif action == "close":
                item_id = (args or {}).get("id", "")
                new_status = (args or {}).get("status", "done")
                if new_status not in ("done", "canceled"):
                    new_status = "done"
                if not item_id:
                    return ToolResult(success=False, result=None, error="id is required")
                item = store.get(item_id)
                if item is None:
                    return ToolResult(success=False, result=None,
                                      error=f"Item not found: {item_id}")
                if not validate_transition(item.status, new_status, policy):
                    return ToolResult(success=False, result=None,
                                      error=f"Cannot close: invalid transition {item.status} → {new_status}")
                item.status = new_status
                item.updated_at = _now_iso()
                store.update(item)
                store.add_event(BacklogEvent(
                    id=_new_id("ev"), item_id=item_id, ts=_now_iso(),
                    type="status_change", message=f"Closed as {new_status}", actor=agent_id,
                ))
                return ToolResult(success=True, result={"item": item.to_dict()})

            # ── ADMIN ─────────────────────────────────────────────────────────
            elif action == "auto_generate_weekly":
                from backlog_generator import generate_from_pressure_digest
                import json
                week_str = (args or {}).get("week_str")
                # Load latest platform digest JSON
                digest_data: Optional[Dict] = None
                platform_dir = policy.get("digest", {}).get("output_dir",
                                           "ops/reports/platform") if hasattr(policy, "get") else "ops/reports/platform"
                # load pressure policy separately for digest output_dir
                try:
                    from architecture_pressure import load_pressure_policy as _lpp
                    _ap = _lpp()
                    platform_dir = _ap.get("digest", {}).get("output_dir", "ops/reports/platform")
                except Exception:
                    pass

                if week_str:
                    digest_file = Path(platform_dir) / f"{week_str}.json"
                else:
                    # Find latest
                    pdir = Path(platform_dir)
                    if pdir.exists():
                        files = sorted(pdir.glob("*.json"), reverse=True)
                        digest_file = files[0] if files else None
                    else:
                        digest_file = None

                if digest_file and Path(digest_file).exists():
                    with open(digest_file, encoding="utf-8") as f:
                        digest_data = json.load(f)
                else:
                    return ToolResult(success=False, result=None,
                                      error="No platform digest found. Run architecture_pressure_tool.digest first.")

                result = generate_from_pressure_digest(
                    digest_data, env=env, store=store, policy=policy,
                    week_str=week_str,
                )
                return ToolResult(success=True, result=result)

            elif action == "cleanup":
                retention_days = int(
                    (args or {}).get("retention_days",
                     policy.get("defaults", {}).get("retention_days", 180))
                )
                deleted = store.cleanup(retention_days)
                return ToolResult(success=True, result={
                    "deleted": deleted, "retention_days": retention_days
                })

            else:
                return ToolResult(success=False, result=None,
                                  error=f"Unknown action: {action}")

        except Exception as e:
            logger.error("backlog_tool error: %s", e)
            return ToolResult(success=False, result=None, error=f"Internal error: {e}")

    async def _architecture_pressure_tool(self, args: Dict, agent_id: str = "ops") -> ToolResult:
        """
        Architecture Pressure Tool — service, dashboard, digest.
        RBAC: tools.pressure.read (service/dashboard), tools.pressure.write (digest).
        """
        action = (args or {}).get("action")
        try:
            from architecture_pressure import (
                compute_pressure, compute_pressure_dashboard, load_pressure_policy,
            )
            from incident_store import get_incident_store
            from alert_store import get_alert_store

            policy = load_pressure_policy()
            env = (args or {}).get("env", "prod")
            lookback_days = int((args or {}).get("lookback_days",
                                 policy.get("defaults", {}).get("lookback_days", 30)))

            inc_store = get_incident_store()
            al_store = get_alert_store()

            # Try to get risk_history_store for regression data
            risk_hist = None
            try:
                from risk_history_store import get_risk_history_store
                risk_hist = get_risk_history_store()
            except Exception:
                pass

            if action == "service":
                service = (args or {}).get("service", "")
                if not service:
                    return ToolResult(success=False, result=None,
                                      error="service is required for service action")
                report = compute_pressure(
                    service, env,
                    lookback_days=lookback_days,
                    policy=policy,
                    incident_store=inc_store,
                    alert_store=al_store,
                    risk_history_store=risk_hist,
                )
                return ToolResult(success=True, result=report)

            elif action == "dashboard":
                top_n = int((args or {}).get("top_n",
                             policy.get("defaults", {}).get("top_n", 10)))
                services = await self._known_services()
                dashboard = compute_pressure_dashboard(
                    env=env,
                    services=services,
                    top_n=top_n,
                    policy=policy,
                    incident_store=inc_store,
                    alert_store=al_store,
                    risk_history_store=risk_hist,
                )
                return ToolResult(success=True, result=dashboard)

            elif action == "digest":
                from platform_priority_digest import weekly_platform_digest
                top_n = int((args or {}).get("top_n",
                             policy.get("digest", {}).get("top_n_in_digest", 10)))
                auto_followup = bool((args or {}).get("auto_followup", True))
                services = await self._known_services()

                # Build pressure reports for all services
                pressure_reports = []
                for svc in services[:top_n * 2]:
                    try:
                        r = compute_pressure(
                            svc, env,
                            lookback_days=lookback_days,
                            policy=policy,
                            incident_store=inc_store,
                            alert_store=al_store,
                            risk_history_store=risk_hist,
                        )
                        pressure_reports.append(r)
                    except Exception as e:
                        logger.warning("pressure digest: skip %s: %s", svc, e)

                result = weekly_platform_digest(
                    env=env,
                    pressure_reports=pressure_reports,
                    policy=policy,
                    write_files=True,
                    auto_followup=auto_followup,
                    incident_store=inc_store if auto_followup else None,
                )
                return ToolResult(success=True, result={
                    "week": result["week"],
                    "env": result["env"],
                    "files_written": result["files_written"],
                    "band_counts": result["band_counts"],
                    "followups_created": result["followups_created"],
                    "markdown_preview": result["markdown"][:1500],
                    "investment_list": result["json_data"].get("investment_priority_list", []),
                })

            else:
                return ToolResult(success=False, result=None,
                                  error=f"Unknown action: {action}")

        except Exception as e:
            logger.error("architecture_pressure_tool error: %s", e)
            return ToolResult(success=False, result=None, error=f"Internal error: {e}")

    async def _known_services(self) -> List[str]:
        """Return known service names from SLO policy."""
        try:
            import yaml
            from pathlib import Path
            for p in [
                Path("config/slo_policy.yml"),
                Path(__file__).resolve().parent.parent.parent / "config" / "slo_policy.yml",
            ]:
                if p.exists():
                    with open(p) as f:
                        data = yaml.safe_load(f) or {}
                    return list(data.get("services", {}).keys())
        except Exception as e:
            logger.warning("_known_services: %s", e)
        return []

    async def close(self):
        await self.http_client.aclose()


def _strip_think_tags(text: str) -> str:
    """Remove <think>...</think> tags from DeepSeek responses."""
    import re
    text = re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL)
    text = re.sub(r'<think>.*$', '', text, flags=re.DOTALL)  # unclosed tag
    return text.strip()


def format_tool_calls_for_response(tool_results: List[Dict], fallback_mode: str = "normal") -> str:
    """
    Format tool results in human-friendly way - NOT raw data!
    
    Args:
        tool_results: List of tool execution results
        fallback_mode: "normal" | "dsml_detected" | "empty_response"
    """
    # Special handling for DSML detection - LLM tried to use tools but got confused
    # If we have successful tool results, show them instead of generic fallback
    if fallback_mode == "dsml_detected":
        # Check if any tool succeeded with a useful result
        if tool_results:
            for tr in tool_results:
                if tr.get("success") and tr.get("result"):
                    # Avoid dumping raw retrieval/search payloads to the user.
                    # These often look like "memory dumps" and are perceived as incorrect answers.
                    tool_name = (tr.get("name") or "").strip()
                    if tool_name in {"memory_search", "web_search", "web_extract", "web_read"}:
                        continue
                    result = str(tr.get("result", ""))
                    if result and len(result) > 10 and "error" not in result.lower():
                        # We have a useful tool result - use it!
                        if len(result) > 600:
                            return result[:600] + "..."
                        return result
        # No useful tool results - give presence acknowledgment
        return "Вибач, відповідь згенерувалась некоректно. Спробуй ще раз (коротше/конкретніше) або повтори питання одним реченням."
    
    if not tool_results:
        if fallback_mode == "empty_response":
            return "Вибач, щось пішло не так. Спробуй ще раз."
        return "Вибач, не вдалося виконати запит."
    
    # Check what tools were used
    tool_names = [tr.get("name", "") for tr in tool_results]
    
    # Check if ANY tool succeeded
    any_success = any(tr.get("success") for tr in tool_results)
    if not any_success:
        # All tools failed - give helpful message
        errors = [tr.get("error", "unknown") for tr in tool_results if tr.get("error")]
        if errors:
            logger.warning(f"All tools failed: {errors}")
        return "Вибач, виникла технічна проблема. Спробуй ще раз або переформулюй питання."
    
    # Image generation - special handling
    if "image_generate" in tool_names:
        for tr in tool_results:
            if tr.get("name") == "image_generate" and tr.get("success"):
                return "✅ Зображення згенеровано!"

    if "comfy_generate_image" in tool_names:
        for tr in tool_results:
            if tr.get("name") == "comfy_generate_image" and tr.get("success"):
                return str(tr.get("result", "✅ Зображення згенеровано через ComfyUI"))

    if "comfy_generate_video" in tool_names:
        for tr in tool_results:
            if tr.get("name") == "comfy_generate_video" and tr.get("success"):
                return str(tr.get("result", "✅ Відео згенеровано через ComfyUI"))
    
    # Web search - show actual results to user
    if "web_search" in tool_names:
        for tr in tool_results:
            if tr.get("name") == "web_search":
                if tr.get("success"):
                    result = tr.get("result", "")
                    if not result:
                        return "🔍 Не знайшов релевантної інформації в інтернеті."
                    
                    # Parse and format results for user
                    lines = result.strip().split("\n")
                    formatted = ["🔍 **Результати пошуку:**\n"]
                    current_title = ""
                    current_url = ""
                    current_snippet = ""
                    count = 0
                    
                    for line in lines:
                        line = line.strip()
                        if line.startswith("- ") and not line.startswith("- URL:"):
                            if current_title and count < 3:  # Show max 3 results
                                formatted.append(f"**{count}. {current_title}**")
                                if current_snippet:
                                    formatted.append(f"   {current_snippet[:150]}...")
                                if current_url:
                                    formatted.append(f"   🔗 {current_url}\n")
                            current_title = line[2:].strip()
                            current_snippet = ""
                            current_url = ""
                            count += 1
                        elif "URL:" in line:
                            current_url = line.split("URL:")[-1].strip()
                        elif line and not line.startswith("-"):
                            current_snippet = line
                    
                    # Add last result
                    if current_title and count <= 3:
                        formatted.append(f"**{count}. {current_title}**")
                        if current_snippet:
                            formatted.append(f"   {current_snippet[:150]}...")
                        if current_url:
                            formatted.append(f"   🔗 {current_url}")
                    
                    if len(formatted) > 1:
                        return "\n".join(formatted)
                    else:
                        return "🔍 Не знайшов релевантної інформації в інтернеті."
                else:
                    return "🔍 Пошук в інтернеті не вдався. Спробуй ще раз."
    
    # Memory search
    if "memory_search" in tool_names:
        for tr in tool_results:
            if tr.get("name") == "memory_search" and tr.get("success"):
                result = tr.get("result", "")
                if "немає інформації" in result.lower() or not result:
                    return "🧠 В моїй пам'яті немає інформації про це."
                # Truncate if too long
                if len(result) > 500:
                    return result[:500] + "..."
                return result
    
    # Graph query
    if "graph_query" in tool_names:
        for tr in tool_results:
            if tr.get("name") == "graph_query" and tr.get("success"):
                result = tr.get("result", "")
                if not result or "не знайдено" in result.lower():
                    return "📊 В базі знань немає інформації про це."
                if len(result) > 500:
                    return result[:500] + "..."
                return result
    
    # Default fallback - check if we have any result to show
    for tr in tool_results:
        if tr.get("success") and tr.get("result"):
            result = str(tr.get("result", ""))
            if result and len(result) > 10:
                # We have something, show it
                if len(result) > 400:
                    return result[:400] + "..."
                return result
    
    # Really nothing useful - be honest
    return "Я обробив твій запит, але не знайшов корисної інформації. Можеш уточнити питання?"

    async def _observability_tool(self, args: Dict) -> ToolResult:
        """
        Observability Tool - Metrics, Logs, Traces.
        
        Provides read-only access to Prometheus, Loki, and Tempo.
        
        Actions:
        - metrics_query: Prometheus instant query
        - metrics_range: Prometheus range query
        - logs_query: Loki log query
        - traces_query: Tempo trace search
        - service_overview: Aggregated service metrics
        
        Security:
        - Allowlist datasources only
        - Query validation/limits
        - Redaction of sensitive data
        - Timeout limits
        """
        import re
        import hashlib
        import os
        import json
        from datetime import datetime, timedelta
        
        action = (args or {}).get("action")
        params = (args or {}).get("params", {})
        
        # Config - datasource allowlist
        PROMETHEUS_URL = os.getenv("PROMETHEUS_URL", "http://prometheus:9090")
        LOKI_URL = os.getenv("LOKI_URL", "http://loki:3100")
        TEMPO_URL = os.getenv("TEMPO_URL", "http://tempo:3200")
        
        # Limits
        MAX_TIME_WINDOW_HOURS = 24
        MAX_SERIES = 200
        MAX_POINTS = 2000
        MAX_BYTES = 300000
        
        # Timeout
        TIMEOUT_SECONDS = 5
        
        # Allowed PromQL prefixes (security)
        ALLOWED_PROMQL_PREFIXES = ["sum(", "rate(", "histogram_quantile(", "avg(", "max(", "min(", "count(", "irate("]
        
        # Redaction patterns
        SECRET_PATTERNS = [
            (re.compile(r'(?i)(api[_-]?key|token|secret|password)\s*=\s*.+'), r'\1=***'),
        ]
        
        def redact(content: str) -> str:
            """Redact secrets from content"""
            for pattern, replacement in SECRET_PATTERNS:
                content = pattern.sub(replacement, content)
            return content
        
        def validate_time_range(time_range: dict) -> tuple:
            """Validate and parse time range"""
            now = datetime.utcnow()
            
            if not time_range:
                # Default: last 30 minutes
                to_time = now
                from_time = now - timedelta(minutes=30)
                return from_time, to_time
            
            try:
                from_time = datetime.fromisoformat(time_range.get("from", "").replace("Z", "+00:00"))
                to_time = datetime.fromisoformat(time_range.get("to", "").replace("Z", "+00:00"))
                
                # Check max window
                hours_diff = (to_time - from_time).total_seconds() / 3600
                if hours_diff > MAX_TIME_WINDOW_HOURS:
                    return None, None
                
                return from_time, to_time
            except:
                return None, None
        
        try:
            if action == "metrics_query":
                query = params.get("query", "")
                datasource = params.get("datasource", "prometheus")
                
                # Validate PromQL
                if not any(query.startswith(prefix) for prefix in ALLOWED_PROMQL_PREFIXES):
                    return ToolResult(
                        success=False,
                        result=None,
                        error=f"Query must start with allowed prefix: {ALLOWED_PROMQL_PREFIXES}"
                    )
                
                # Make request to Prometheus
                try:
                    response = await self.http_client.get(
                        f"{PROMETHEUS_URL}/api/v1/query",
                        params={"query": query},
                        timeout=TIMEOUT_SECONDS
                    )
                    
                    if response.status_code == 200:
                        data = response.json()
                        results = data.get("data", {}).get("result", [])
                        
                        # Limit results
                        results = results[:MAX_SERIES]
                        
                        # Redact any secrets
                        for r in results:
                            if "metric" in r:
                                r["metric"] = {k: redact(str(v)) for k, v in r["metric"].items()}
                        
                        return ToolResult(success=True, result={
                            "summary": f"Found {len(results)} series",
                            "datasource": "prometheus",
                            "results": results,
                            "limits_applied": {"max_series": MAX_SERIES}
                        })
                    else:
                        return ToolResult(success=False, result=None, error=f"Prometheus error: {response.status_code}")
                except Exception as e:
                    return ToolResult(success=True, result={
                        "summary": "Prometheus unavailable",
                        "datasource": "prometheus",
                        "error": str(e),
                        "results": []
                    })
            
            elif action == "metrics_range":
                query = params.get("query", "")
                time_range = params.get("time_range", {})
                step_seconds = params.get("step_seconds", 30)
                
                # Validate time range
                from_time, to_time = validate_time_range(time_range)
                if from_time is None:
                    return ToolResult(success=False, result=None, error=f"Time window exceeds {MAX_TIME_WINDOW_HOURS}h limit")
                
                # Validate step
                if step_seconds < 15 or step_seconds > 300:
                    return ToolResult(success=False, result=None, error="step_seconds must be between 15-300")
                
                # Validate PromQL
                if not any(query.startswith(prefix) for prefix in ALLOWED_PROMQL_PREFIXES):
                    return ToolResult(
                        success=False,
                        result=None,
                        error="Query must start with allowed prefix"
                    )
                
                try:
                    response = await self.http_client.get(
                        f"{PROMETHEUS_URL}/api/v1/query_range",
                        params={
                            "query": query,
                            "start": from_time.isoformat() + "Z",
                            "end": to_time.isoformat() + "Z",
                            "step": f"{step_seconds}s"
                        },
                        timeout=TIMEOUT_SECONDS
                    )
                    
                    if response.status_code == 200:
                        data = response.json()
                        results = data.get("data", {}).get("result", [])
                        
                        # Limit
                        results = results[:MAX_SERIES]
                        
                        return ToolResult(success=True, result={
                            "summary": f"Found {len(results)} series",
                            "datasource": "prometheus",
                            "time_range": {"from": from_time.isoformat(), "to": to_time.isoformat()},
                            "results": results,
                            "limits_applied": {"max_series": MAX_SERIES, "max_points": MAX_POINTS}
                        })
                except Exception as e:
                    return ToolResult(success=True, result={
                        "summary": "Prometheus unavailable",
                        "error": str(e),
                        "results": []
                    })
            
            elif action == "logs_query":
                query = params.get("query", "")
                time_range = params.get("time_range", {})
                limit = min(params.get("limit", 200), 500)
                
                # Validate time range
                from_time, to_time = validate_time_range(time_range)
                if from_time is None:
                    return ToolResult(success=False, result=None, error=f"Time window exceeds {MAX_TIME_WINDOW_HOURS}h limit")
                
                try:
                    response = await self.http_client.get(
                        f"{LOKI_URL}/loki/api/v1/query_range",
                        params={
                            "query": query,
                            "limit": limit,
                            "start": int(from_time.timestamp() * 1e9),
                            "end": int(to_time.timestamp() * 1e9)
                        },
                        timeout=TIMEOUT_SECONDS * 2
                    )
                    
                    if response.status_code == 200:
                        data = response.json()
                        results = data.get("data", {}).get("result", [])
                        
                        # Redact secrets in log lines
                        for r in results:
                            if "values" in r:
                                r["values"] = [
                                    [ts, redact(line)] for ts, line in r["values"]
                                ]
                        
                        return ToolResult(success=True, result={
                            "summary": f"Found {len(results)} log streams",
                            "datasource": "loki",
                            "time_range": {"from": from_time.isoformat(), "to": to_time.isoformat()},
                            "results": results,
                            "limits_applied": {"max_logs": limit, "max_bytes": MAX_BYTES}
                        })
                except Exception as e:
                    return ToolResult(success=True, result={
                        "summary": "Loki unavailable",
                        "error": str(e),
                        "results": []
                    })
            
            elif action == "traces_query":
                trace_id = params.get("trace_id")
                service = params.get("service")
                
                if trace_id:
                    # Get specific trace
                    try:
                        response = await self.http_client.get(
                            f"{TEMPO_URL}/api/traces/{trace_id}",
                            timeout=TIMEOUT_SECONDS
                        )
                        
                        if response.status_code == 200:
                            data = response.json()
                            return ToolResult(success=True, result={
                                "summary": "Trace found",
                                "datasource": "tempo",
                                "trace": data
                            })
                        else:
                            return ToolResult(success=False, result=None, error="Trace not found")
                    except Exception as e:
                        return ToolResult(success=True, result={
                            "summary": "Tempo unavailable",
                            "error": str(e)
                        })
                else:
                    # Search by service (basic implementation)
                    return ToolResult(success=True, result={
                        "summary": "Trace search by service not implemented in MVP",
                        "datasource": "tempo",
                        "hint": "Use trace_id parameter for MVP"
                    })
            
            elif action == "service_overview":
                service = params.get("service", "gateway")
                time_range = params.get("time_range", {})
                
                # Validate time range
                from_time, to_time = validate_time_range(time_range)
                if from_time is None:
                    from_time = datetime.utcnow() - timedelta(minutes=30)
                    to_time = datetime.utcnow()
                
                # Build overview queries
                latency_query = f'histogram_quantile(0.95, rate(http_request_duration_seconds_bucket{{service="{service}"}}[5m]))'
                error_query = f'sum(rate(http_requests_total{{service="{service}",status=~"5.."}}[5m])) / sum(rate(http_requests_total{{service="{service}"}}[5m]))'
                throughput_query = f'sum(rate(http_requests_total{{service="{service}"}}[5m]))'
                
                overview = {
                    "service": service,
                    "time_range": {"from": from_time.isoformat(), "to": to_time.isoformat()},
                    "metrics": {}
                }
                
                # Try to get metrics (best effort)
                for metric_name, query in [("latency_p95", latency_query), ("error_rate", error_query), ("throughput", throughput_query)]:
                    try:
                        response = await self.http_client.get(
                            f"{PROMETHEUS_URL}/api/v1/query",
                            params={"query": query},
                            timeout=TIMEOUT_SECONDS
                        )
                        
                        if response.status_code == 200:
                            data = response.json()
                            results = data.get("data", {}).get("result", [])
                            if results:
                                overview["metrics"][metric_name] = results[0].get("value", [None, None])[1]
                    except:
                        pass
                
                return ToolResult(success=True, result=overview)

            elif action == "slo_snapshot":
                service = params.get("service", "gateway")
                env = params.get("env", "prod")
                window_minutes = min(int(params.get("window_minutes", 60)), 1440)

                import yaml as _yaml
                slo_path = os.path.join(
                    os.getenv("REPO_ROOT", str(Path(__file__).parent.parent.parent)),
                    "config", "slo_policy.yml",
                )
                try:
                    with open(slo_path, "r") as f:
                        slo_cfg = _yaml.safe_load(f) or {}
                except Exception:
                    slo_cfg = {}

                defaults = slo_cfg.get("defaults", {})
                svc_cfg = (slo_cfg.get("services") or {}).get(service, {})
                thresholds = {
                    "latency_p95_ms": svc_cfg.get("latency_p95_ms", defaults.get("latency_p95_ms", 300)),
                    "error_rate_pct": svc_cfg.get("error_rate_pct", defaults.get("error_rate_pct", 1.0)),
                }

                from_time = datetime.utcnow() - timedelta(minutes=window_minutes)
                to_time = datetime.utcnow()
                latency_query = (
                    f'histogram_quantile(0.95, rate(http_request_duration_seconds_bucket'
                    f'{{service="{service}"}}[{window_minutes}m])) * 1000'
                )
                error_query = (
                    f'sum(rate(http_requests_total{{service="{service}",status=~"5.."}}[{window_minutes}m])) '
                    f'/ sum(rate(http_requests_total{{service="{service}"}}[{window_minutes}m])) * 100'
                )
                rate_query = f'sum(rate(http_requests_total{{service="{service}"}}[{window_minutes}m]))'

                metrics: Dict = {}
                for name, q in [("latency_p95_ms", latency_query),
                                ("error_rate_pct", error_query),
                                ("req_rate_rps", rate_query)]:
                    try:
                        resp = await self.http_client.get(
                            f"{PROMETHEUS_URL}/api/v1/query",
                            params={"query": q},
                            timeout=TIMEOUT_SECONDS,
                        )
                        if resp.status_code == 200:
                            results = resp.json().get("data", {}).get("result", [])
                            if results:
                                raw = results[0].get("value", [None, None])[1]
                                metrics[name] = round(float(raw), 2) if raw else None
                    except Exception:
                        pass

                violations = []
                lat = metrics.get("latency_p95_ms")
                err = metrics.get("error_rate_pct")
                if lat is not None and lat > thresholds["latency_p95_ms"]:
                    violations.append("latency_p95")
                if err is not None and err > thresholds["error_rate_pct"]:
                    violations.append("error_rate")

                skipped = not metrics
                return ToolResult(success=True, result={
                    "service": service,
                    "env": env,
                    "window_minutes": window_minutes,
                    "metrics": metrics,
                    "thresholds": thresholds,
                    "violations": violations,
                    "skipped": skipped,
                })

            else:
                return ToolResult(
                    success=False,
                    result=None,
                    error=f"Unknown action: {action}"
                )
        
        except Exception as e:
            logger.error(f"Observability tool error: {e}")
            return ToolResult(success=False, result=None, error=f"Internal error: {str(e)}")

    async def _config_linter_tool(self, args: Dict) -> ToolResult:
        """
        Config Linter Tool - Secrets/Config Policy Enforcement
        
        Checks PR/diff/files for:
        - Secrets (API keys, tokens, private keys)
        - Dangerous config defaults (DEBUG, CORS, auth bypass)
        - Missing required settings (timeouts, rate limits)
        - Compose/K8s sanity issues
        
        Deterministic, fast, no LLM required.
        """
        import re
        import os
        import hashlib
        import yaml
        import json
        
        source = (args or {}).get("source", {})
        options = (args or {}).get("options", {})
        
        source_type = source.get("type", "diff_text")
        strict = options.get("strict", False)
        max_chars = options.get("max_chars", 400000)
        max_files = options.get("max_files", 200)
        mask_evidence = options.get("mask_evidence", True)
        include_recommendations = options.get("include_recommendations", True)
        
        ALLOWED_EXTENSIONS = {
            ".env", ".yml", ".yaml", ".json", ".toml",
            ".tf", ".dockerfile", "docker-compose"
        }
        
        ALLOWED_DIRS = {
            "config", "ops", "k8s", "deploy", ".github",
            "scripts", "services", "packages"
        }
        
        def mask_value(value: str, show_chars: int = 4) -> str:
            if not value or len(value) <= show_chars:
                return "***"
            return value[:show_chars] + "*" * (len(value) - show_chars)
        
        def compute_hash(content: str) -> str:
            return hashlib.sha256(content.encode()).hexdigest()[:16]
        
        SECRET_PATTERNS = [
            (re.compile(r"-----BEGIN\s+(RSA\s+|EC\s+|DSA\s+|OPENSSH\s+|PGP\s+|PRIVATE\s+KEY)-----"), "CFL-001", "private_key", "critical"),
            (re.compile(r"-----BEGIN\s+CERTIFICATE-----"), "CFL-002", "certificate_block", "high"),
            (re.compile(r"(?i)(api[_-]?key|apikey)\s*[:=]\s*['\"]?([a-zA-Z0-9_\-]{16,})['\"]?"), "CFL-003", "api_key", "critical"),
            (re.compile(r"(?i)(secret|token|password|passwd|pwd)\s*[:=]\s*['\"]?([a-zA-Z0-9_\-\.]{8,})['\"]?"), "CFL-004", "secret_token", "critical"),
            (re.compile(r"(?i)(bearer\s+|token\s*[:=]\s*['\"]?)eyJ[a-zA-Z0-9_-]*\.eyJ[a-zA-Z0-9_-]*\.[a-zA-Z0-9_-]*"), "CFL-005", "jwt_token", "critical"),
            (re.compile(r"sk-[a-zA-Z0-9]{32,}"), "CFL-006", "openai_key", "critical"),
            (re.compile(r"ghp_[a-zA-Z0-9]{36,}"), "CFL-007", "github_token", "critical"),
            (re.compile(r"xoxb-[a-zA-Z0-9]{10,}"), "CFL-008", "slack_token", "critical"),
            (re.compile(r"AKIA[0-9A-Z]{16}"), "CFL-009", "aws_key", "critical"),
            (re.compile(r"(?i)(firebase|googleapis|cloudflare|strip|stripe)[_-]?(api)?[_-]?key\s*[:=]\s*['\"]?[a-zA-Z0-9_\-]{20,}['\"]?"), "CFL-010", "cloud_api_key", "critical"),
            (re.compile(r"(?i)password\s*[:=]\s*['\"]?(root|admin|123456|password|qwerty|letmein)['\"]?", re.IGNORECASE), "CFL-011", "weak_password", "high"),
        ]
        
        DANGEROUS_CONFIG_PATTERNS = [
            (re.compile(r"(?i)debug\s*[:=]\s*['\"]?true['\"]?"), "CFL-101", "debug_enabled", "high", "DEBUG=true found - may expose sensitive info in production"),
            (re.compile(r"(?i)(env|environment)\s*[:=]\s*['\"]?(dev|development|test)['\"]?"), "CFL-102", "dev_env_in_config", "high", "Development environment set in config"),
            (re.compile(r"(?i)access[_-]control[_-]allow[_-]origin\s*[:=]\s*['\"]?\*['\"]?"), "CFL-103", "cors_wildcard", "high", "CORS allows all origins"),
            (re.compile(r"(?i)(auth[_-]?(disabled|bypass)|skip[_-]?auth|rbac\s*[:=]\s*false)"), "CFL-104", "auth_bypass", "critical", "Authentication is disabled"),
            (re.compile(r"(?i)(tls|ssl)\s*[:=]\s*['\"]?(false|disabled|off)['\"]?"), "CFL-105", "tls_disabled", "critical", "TLS/SSL is disabled"),
            (re.compile(r"(?i)allowed[_-]hosts\s*[:=]\s*\[\s*['\"]?\*['\"]?\s*\]"), "CFL-106", "allowed_hosts_wildcard", "medium", "Allowed hosts set to all"),
        ]
        
        MISSING_PATTERNS = [
            (re.compile(r"(?i)(timeout|timelimit)\s*[:=]\s*['\"]?(\d+ms|0)['\"]?"), "CFL-201", "timeout_missing", "medium", "No timeout or zero timeout detected"),
        ]
        
        K8S_COMPOSE_PATTERNS = [
            (re.compile(r"user:\s*root"), "CFL-301", "container_root_user", "medium", "Container runs as root"),
            (re.compile(r"privileged:\s*true"), "CFL-302", "privileged_container", "high", "Container has privileged access"),
            (re.compile(r"hostPath:\s*\n\s*path:\s*['\"]/(etc|var|usr|sys|proc)"), "CFL-303", "sensitive_hostpath", "high", "Mounting sensitive host path"),
            (re.compile(r"resources:\s*\n\s*limits:\s*\n\s*memory:"), None, "resource_limits_set", "info", "Resource limits defined"),
            (re.compile(r"(?i)read[_-]only:\s*true"), None, "readonly_rootfs", "info", "Root filesystem is read-only"),
        ]
        
        def check_content(file_path: str, content: str, is_diff: bool = False) -> list:
            findings = []
            lines = content.split("\n")
            
            for i, line in enumerate(lines, 1):
                if is_diff and not line.startswith("+"):
                    continue
                    
                check_line = line.lstrip("+").lstrip("-")
                
                for pattern, rule_id, rule_name, severity, *desc in SECRET_PATTERNS:
                    match = pattern.search(check_line)
                    if match:
                        masked = check_line
                        if mask_evidence:
                            for group in match.groups():
                                if group:
                                    masked = masked.replace(group, mask_value(group))
                        
                        findings.append({
                            "id": rule_id,
                            "rule": rule_name,
                            "severity": severity,
                            "file": file_path,
                            "lines": f"L{i}",
                            "evidence": masked[:200] if mask_evidence else masked[:200],
                            "why": f"Secret detected: {rule_name}",
                            "fix": "Remove or use environment variable reference"
                        })
                
                for pattern, rule_id, rule_name, severity, *desc in DANGEROUS_CONFIG_PATTERNS:
                    if pattern.search(check_line):
                        why = desc[0] if desc else f"Dangerous config: {rule_name}"
                        findings.append({
                            "id": rule_id,
                            "rule": rule_name,
                            "severity": severity,
                            "file": file_path,
                            "lines": f"L{i}",
                            "evidence": check_line[:200],
                            "why": why,
                            "fix": "Use secure default or environment-specific config"
                        })
                
                for pattern, rule_id, rule_name, severity, *desc in MISSING_PATTERNS:
                    if pattern.search(check_line):
                        why = desc[0] if desc else f"Missing: {rule_name}"
                        findings.append({
                            "id": rule_id,
                            "rule": rule_name,
                            "severity": severity,
                            "file": file_path,
                            "lines": f"L{i}",
                            "evidence": check_line[:200],
                            "why": why,
                            "fix": "Configure proper timeout value"
                        })
                
                if file_path.endswith((".yml", ".yaml")) or "docker" in file_path.lower():
                    for pattern, rule_id, rule_name, severity, *desc in K8S_COMPOSE_PATTERNS:
                        if rule_id and pattern.search(check_line):
                            why = desc[0] if desc else f"K8s/Compose issue: {rule_name}"
                            findings.append({
                                "id": rule_id,
                                "rule": rule_name,
                                "severity": severity,
                                "file": file_path,
                                "lines": f"L{i}",
                                "evidence": check_line[:200],
                                "why": why,
                                "fix": "Follow K8s security best practices"
                            })
            
            return findings
        
        def parse_diff(diff_text: str) -> dict:
            files = {}
            current_file = None
            current_content = []
            
            for line in diff_text.split("\n"):
                if line.startswith("+++ b/") or line.startswith("--- a/") or line.startswith("+++ "):
                    if current_file and current_content:
                        files[current_file] = "\n".join(current_content)
                    current_file = line.replace("+++ b/", "").replace("--- a/", "").replace("+++ ", "").strip()
                    current_content = []
                elif line.startswith("+") and not line.startswith("+++"):
                    current_content.append(line)
                elif line.startswith(" ") or line.startswith("-") and not line.startswith("---"):
                    current_content.append(line)
            
            if current_file and current_content:
                files[current_file] = "\n".join(current_content)
            
            return files
        
        def is_allowed_file(path: str) -> bool:
            path_lower = path.lower()
            
            for ext in ALLOWED_EXTENSIONS:
                if path_lower.endswith(ext):
                    return True
            
            for dir_name in ALLOWED_DIRS:
                if dir_name in path_lower:
                    return True
            
            return False
        
        try:
            all_findings = []
            files_scanned = 0
            rules_run = len(SECRET_PATTERNS) + len(DANGEROUS_CONFIG_PATTERNS) + len(MISSING_PATTERNS) + len(K8S_COMPOSE_PATTERNS)
            
            if source_type == "diff_text":
                diff_text = source.get("diff_text", "")
                
                if len(diff_text) > max_chars:
                    return ToolResult(
                        success=False,
                        result=None,
                        error=f"Diff exceeds max_chars limit ({max_chars})"
                    )
                
                files = parse_diff(diff_text)
                
                for file_path, content in files.items():
                    if not is_allowed_file(file_path):
                        continue
                    
                    findings = check_content(file_path, content, is_diff=True)
                    all_findings.extend(findings)
                    files_scanned += 1
            
            elif source_type == "paths":
                paths = source.get("paths", [])
                
                if len(paths) > max_files:
                    return ToolResult(
                        success=False,
                        result=None,
                        error=f"Too many files ({len(paths)}), max is {max_files}"
                    )
                
                repo_root = os.getenv("REPO_ROOT", "/Users/apple/github-projects/microdao-daarion")
                
                for file_path in paths:
                    abs_path = file_path
                    if not os.path.isabs(file_path):
                        abs_path = os.path.join(repo_root, file_path)
                    
                    normalized = os.path.normpath(abs_path)
                    
                    if ".." in normalized or not normalized.startswith(repo_root):
                        all_findings.append({
                            "id": "CFL-999",
                            "rule": "path_traversal_blocked",
                            "severity": "high",
                            "file": file_path,
                            "lines": "N/A",
                            "evidence": "Path traversal attempt blocked",
                            "why": "Path contains .. or escapes repo root",
                            "fix": "Use relative paths within repo"
                        })
                        continue
                    
                    if not os.path.exists(abs_path):
                        continue
                    
                    if not is_allowed_file(abs_path):
                        continue
                    
                    try:
                        with open(abs_path, "r", encoding="utf-8") as f:
                            content = f.read(max_chars)
                        
                        findings = check_content(abs_path, content)
                        all_findings.extend(findings)
                        files_scanned += 1
                    except Exception as e:
                        logger.warning(f"Could not read {abs_path}: {e}")
            
            blocking = []
            non_blocking = []
            
            critical_severities = {"critical", "high"}
            medium_severities = {"medium"}
            
            for f in all_findings:
                if f["severity"] in critical_severities:
                    blocking.append(f)
                else:
                    non_blocking.append(f)
            
            if strict:
                medium_findings = [f for f in non_blocking if f["severity"] in medium_severities]
                blocking.extend(medium_findings)
                non_blocking = [f for f in non_blocking if f["severity"] not in medium_severities]
            
            blocking.sort(key=lambda x: (x["severity"], x["id"]))
            non_blocking.sort(key=lambda x: (x["severity"], x["id"]))
            
            total_blocking = len(blocking)
            total_findings = len(non_blocking)
            
            if total_blocking > 0:
                summary = f"⚠️ Found {total_blocking} blocking issue(s), {total_findings} warning(s)"
            else:
                summary = f"✅ No blocking issues. {total_findings} warning(s) found"
            
            result = {
                "summary": summary,
                "blocking": blocking,
                "findings": non_blocking if include_recommendations else [],
                "stats": {
                    "files_scanned": files_scanned,
                    "rules_run": rules_run,
                    "blocking_count": total_blocking,
                    "total_findings": total_blocking + total_findings
                }
            }
            
            logger.info(f"Config lint: scanned {files_scanned} files, {total_blocking} blocking, {total_findings} warnings")
            
            return ToolResult(success=True, result=result)
        
        except Exception as e:
            logger.error(f"Config linter error: {e}")
            return ToolResult(success=False, result=None, error=f"Internal error: {str(e)}")

    async def _threatmodel_tool(self, args: Dict) -> ToolResult:
        """
        ThreatModel & Security Checklist Tool
        
        Analyzes service artifacts (OpenAPI, diff, text) to generate:
        - Assets (data, secrets, services, identities)
        - Trust boundaries
        - Entry points (HTTP, NATS, cron, webhooks, tools)
        - Threats (STRIDE-based)
        - Recommended controls
        - Security test checklist
        
        Deterministic heuristics (no LLM required), with optional LLM enrichment.
        """
        import re
        import hashlib
        import json
        
        action = (args or {}).get("action", "analyze_service")
        inputs = (args or {}).get("inputs", {})
        options = (args or {}).get("options", {})
        
        service_name = inputs.get("service_name", "unknown")
        artifacts = inputs.get("artifacts", [])
        max_chars = options.get("max_chars", 600000)
        strict = options.get("strict", False)
        risk_profile = options.get("risk_profile", "default")
        
        SENSITIVE_PATTERNS = [
            (re.compile(r"(?i)(token|secret|password|key|api_key|apikey|jwt|bearer)"), "secret", "high"),
            (re.compile(r"(?i)(email|phone|ssn|passport|credit|card|payment)"), "pii", "high"),
            (re.compile(r"(?i)(wallet|address|private_key|mnemonic)"), "crypto", "high"),
            (re.compile(r"(?i)(user_id|userid|customer_id|account_id)"), "identity", "medium"),
            (re.compile(r"(?i)(session|auth)"), "auth", "medium"),
        ]
        
        AUTH_PATTERNS = [
            (re.compile(r"(?i)(securitySchemes|components.*security)"), "jwt"),
            (re.compile(r"(?i)(api[_-]?key)"), "api_key"),
            (re.compile(r"(?i)(bearer)"), "jwt"),
            (re.compile(r"(?i)(oauth2|client_credentials)"), "oauth2"),
            (re.compile(r"(?i)(mtls|mTLS)"), "mTLS"),
        ]
        
        DANGEROUS_PATTERNS = [
            (re.compile(r"(?i)(exec|eval|compile|__import__|subprocess|os\.system|shell=True)"), "RCE", "critical"),
            (re.compile(r"(?i)(requests\.get|urllib\.|urlopen|fetch.*url)"), "SSRF", "high"),
            (re.compile(r"(?i)(file_save|write|upload|multipart)"), "FileUpload", "high"),
            (re.compile(r"(?i)(sql|injection|query|format.*%)"), "SQLInjection", "critical"),
            (re.compile(r"(?i)(deserialize|pickle|yaml\.load|marshal)"), "Deserialization", "critical"),
            (re.compile(r"(?i)(random\.random|math\.random)"), "WeakRandom", "medium"),
            (re.compile(r"(?i)(hashlib\.md5|sha1)"), "WeakCrypto", "medium"),
        ]
        
        def extract_entrypoints_from_openapi(content: str) -> list:
            entrypoints = []
            try:
                import yaml
                spec = yaml.safe_load(content)
                if not spec:
                    return entrypoints
                
                paths = spec.get("paths", {})
                for path, methods in paths.items():
                    if isinstance(methods, dict):
                        for method, details in methods.items():
                            if method.upper() in ["GET", "POST", "PUT", "DELETE", "PATCH"]:
                                auth = "none"
                                for pattern, auth_type in AUTH_PATTERNS:
                                    if pattern.search(str(details)):
                                        auth = auth_type
                                        break
                                
                                entrypoints.append({
                                    "type": "http",
                                    "id": f"{method.upper()} {path}",
                                    "auth": auth,
                                    "notes": details.get("summary", "")
                                })
            except:
                pass
            return entrypoints
        
        def extract_entrypoints_from_diff(content: str) -> list:
            entrypoints = []
            
            http_patterns = [
                r"@(app|router)\.(get|post|put|delete|patch)\([\"'](/[^\"']+)",
                r"@route\([\"'](/[^\"']+)",
                r"path\s*=\s*[\"'](/[^\"']+)",
                r"FastAPI\(\)|Flask\(|Express\(\)",
            ]
            
            for pattern in http_patterns:
                for match in re.finditer(pattern, content, re.IGNORECASE):
                    path = match.group(1) if match.lastindex else match.group(0)
                    if path and path.startswith("/"):
                        entrypoints.append({
                            "type": "http",
                            "id": path,
                            "auth": "unknown",
                            "notes": "Found in diff"
                        })
            
            nats_pattern = r"(publish|subscribe)\([\"']([^\"']+)"
            for match in re.finditer(nats_pattern, content):
                subject = match.group(2) if match.lastindex else ""
                if subject:
                    entrypoints.append({
                        "type": "nats",
                        "id": subject,
                        "auth": "unknown",
                        "notes": "NATS subject"
                    })
            
            cron_pattern = r"(cron|schedule|@.*\s+)"
            if cron_pattern.lower() in content.lower():
                entrypoints.append({
                    "type": "cron",
                    "id": "scheduled_task",
                    "auth": "unknown",
                    "notes": "Scheduled task detected"
                })
            
            webhook_pattern = r"(webhook|callback|on_.*event)"
            if webhook_pattern.lower() in content.lower():
                entrypoints.append({
                    "type": "webhook",
                    "id": "webhook_handler",
                    "auth": "unknown",
                    "notes": "Webhook handler detected"
                })
            
            tool_pattern = r"(def\s+tool_|async\s+def\s+tool_)"
            if tool_pattern in content:
                entrypoints.append({
                    "type": "tool",
                    "id": "tool_endpoint",
                    "auth": "rbac",
                    "notes": "Tool execution endpoint"
                })
            
            return entrypoints
        
        def extract_assets(content: str) -> list:
            assets = []
            seen = set()
            
            for pattern, asset_type, sensitivity in SENSITIVE_PATTERNS:
                for match in pattern.finditer(content):
                    name = match.group(0)
                    if name.lower() not in seen:
                        seen.add(name.lower())
                        assets.append({
                            "name": name,
                            "type": asset_type,
                            "sensitivity": sensitivity
                        })
            
            return assets
        
        def get_default_trust_boundaries() -> list:
            return [
                {"name": "client_to_gateway", "between": ["client", "gateway"], "notes": "External traffic"},
                {"name": "gateway_to_service", "between": ["gateway", "internal_service"], "notes": "Internal API"},
                {"name": "service_to_db", "between": ["service", "database"], "notes": "Data access"},
                {"name": "service_to_external", "between": ["service", "external_api"], "notes": "Outbound calls"},
            ]
        
        STRIDE_THREATS = {
            "S": [
                {"title": "User spoofing", "scenario": "Attacker impersonates valid user", "mitigations": ["Strong authentication", "MFA", "Session management"], "tests": ["Brute force protection", "Account lockout"]},
                {"title": "JWT tampering", "scenario": "Attacker modifies JWT token claims", "mitigations": ["Signature verification", "Algorithm allowlist"], "tests": ["Token signature validation"]},
            ],
            "T": [
                {"title": "Data tampering in transit", "scenario": "MITM modifies request/response", "mitigations": ["TLS 1.2+", "Certificate pinning"], "tests": ["TLS version check", "Certificate validation"]},
            ],
            "R": [
                {"title": "Repudiation", "scenario": "User denies performing action", "mitigations": ["Audit logging", "Immutable logs", "Digital signatures"], "tests": ["Log integrity checks"]},
            ],
            "I": [
                {"title": "Information disclosure", "scenario": "Sensitive data exposed", "mitigations": ["Encryption at rest", "Redaction", "Access controls"], "tests": ["PII detection", "Secret scanning"]},
                {"title": "Error message leakage", "scenario": "Stack traces exposed", "mitigations": ["Generic error messages", "Error handling"], "tests": ["Error response inspection"]},
            ],
            "D": [
                {"title": "Denial of service", "scenario": "Service becomes unavailable", "mitigations": ["Rate limiting", "Resource limits", "Circuit breakers"], "tests": ["Load testing", "Rate limit enforcement"]},
                {"title": "Queue flooding", "scenario": "Message queue overwhelmed", "mitigations": ["Queue limits", "Dead letter handling"], "tests": ["Queue capacity testing"]},
            ],
            "E": [
                {"title": "Privilege escalation", "scenario": "User gains higher privileges", "mitigations": ["RBAC", "Principle of least privilege"], "tests": ["Authorization bypass tests"]},
                {"title": "RCE via code injection", "scenario": "Attacker executes arbitrary code", "mitigations": ["Input sanitization", "Sandboxing", "No eval"], "tests": ["Code injection payloads"]},
            ],
        }
        
        SPECIFIC_THREATS = {
            "SSRF": [{"title": "Server-Side Request Forgery", "scenario": "Attacker forces server to make requests to internal services", "mitigations": ["URL allowlist", "No follow redirects"], "tests": ["SSRF payload testing"]}],
            "SQLInjection": [{"title": "SQL Injection", "scenario": "Attacker injects malicious SQL", "mitigations": ["Parameterized queries", "ORM", "Input validation"], "tests": ["SQL injection payloads"]}],
            "FileUpload": [{"title": "Malicious file upload", "scenario": "Attacker uploads malicious files", "mitigations": ["File type validation", "Content scanning", "Storage separation"], "tests": ["File type bypass testing"]}],
            "Deserialization": [{"title": "Insecure deserialization", "scenario": "Attacker deserializes malicious payload", "mitigations": ["Avoid pickle/yaml unsafe", "JSON preferred"], "tests": ["Deserialization payload testing"]}],
            "RCE": [{"title": "Remote Code Execution", "scenario": "Attacker executes system commands", "mitigations": ["No shell=True", "Input sanitization", "Sandboxing"], "tests": ["RCE payload testing"]}],
        }
        
        AGENTIC_THREATS = [
            {"id": "TM-AI-001", "category": "I", "title": "Prompt injection", "scenario": "User input manipulates agent behavior", "impact": "high", "likelihood": "med", "risk": "high", "mitigations": ["Input sanitization", "Output validation", "Prompt isolation"], "tests": ["Prompt injection payloads"]},
            {"id": "TM-AI-002", "category": "I", "title": "Data exfiltration via tool", "scenario": "Tool returns sensitive data to user", "impact": "high", "likelihood": "low", "risk": "med", "mitigations": ["Output redaction", "PII filtering"], "tests": ["Sensitive data in responses"]},
            {"id": "TM-AI-003", "category": "E", "title": "Confused deputy", "scenario": "Agent uses tool with higher privileges", "impact": "critical", "likelihood": "low", "risk": "high", "mitigations": ["Tool RBAC", "Privilege separation"], "tests": ["Privilege escalation via tools"]},
            {"id": "TM-AI-004", "category": "I", "title": "Tool supply chain", "scenario": "Malicious tool/plugin injected", "impact": "critical", "likelihood": "low", "risk": "med", "mitigations": ["Tool signing", "Sandboxing"], "tests": ["Tool manifest verification"]},
        ]
        
        PUBLIC_API_THREATS = [
            {"id": "TM-PA-001", "category": "D", "title": "API abuse/rate limit bypass", "scenario": "Attacker bypasses rate limits", "impact": "high", "likelihood": "high", "risk": "high", "mitigations": ["WAF", "IP-based rate limiting", "API keys"], "tests": ["Rate limit testing"]},
            {"id": "TM-PA-002", "category": "S", "title": "Account takeover", "scenario": "Attacker takes over user account", "impact": "critical", "likelihood": "med", "risk": "high", "mitigations": ["MFA", "Password policies", "Breach detection"], "tests": ["Account takeover scenarios"]},
            {"id": "TM-PA-003", "category": "T", "title": "API parameter tampering", "scenario": "Attacker modifies API parameters", "impact": "high", "likelihood": "med", "risk": "high", "mitigations": ["Schema validation", "Integrity checks"], "tests": ["Parameter fuzzing"]},
        ]
        
        SECURITY_CHECKLIST_TEMPLATE = [
            {"type": "auth", "item": "Authentication implemented on all endpoints", "priority": "p0"},
            {"type": "authz", "item": "Authorization (RBAC) implemented", "priority": "p0"},
            {"type": "input_validation", "item": "Input validation on all user inputs", "priority": "p0"},
            {"type": "secrets", "item": "No secrets in code/config (use env vars)", "priority": "p0"},
            {"type": "crypto", "item": "Strong crypto (TLS 1.2+, AES-256)", "priority": "p0"},
            {"type": "logging", "item": "Security events logged", "priority": "p1"},
            {"type": "logging", "item": "No PII in logs", "priority": "p1"},
            {"type": "auth", "item": "MFA available", "priority": "p1"},
            {"type": "authz", "item": "Privilege separation implemented", "priority": "p1"},
            {"type": "input_validation", "item": "Rate limiting implemented", "priority": "p1"},
            {"type": "deps", "item": "Dependencies scanned for vulnerabilities", "priority": "p1"},
            {"type": "infra", "item": "WAF configured", "priority": "p2"},
            {"type": "tooling", "item": "Security testing in CI/CD", "priority": "p2"},
        ]
        
        def generate_checklist(assets: list, entrypoints: list, threats: list) -> list:
            checklist = list(SECURITY_CHECKLIST_TEMPLATE)
            
            has_auth_none = any(ep.get("auth") == "none" for ep in entrypoints)
            if has_auth_none:
                checklist.insert(0, {"type": "auth", "item": "CRITICAL: No auth on endpoints - must fix", "priority": "p0"})
            
            has_secrets = any(a.get("sensitivity") == "high" and a.get("type") == "secret" for a in assets)
            if has_secrets:
                checklist.append({"type": "secrets", "item": "Secrets handling verified", "priority": "p0"})
            
            has_rce = any("RCE" in str(t.get("title", "")) for t in threats)
            if has_rce:
                checklist.append({"type": "input_validation", "item": "RCE prevention verified", "priority": "p0"})
            
            has_external = any(ep.get("type") == "http" for ep in entrypoints)
            if has_external:
                checklist.append({"type": "auth", "item": "External API security verified", "priority": "p0"})
            
            return checklist
        
        def score_risk(impact: str, likelihood: str) -> str:
            impact_map = {"low": 1, "med": 2, "high": 3}
            likelihood_map = {"low": 1, "med": 2, "high": 3}
            
            score = impact_map.get(impact, 1) * likelihood_map.get(likelihood, 1)
            
            if score >= 6:
                return "high"
            elif score >= 3:
                return "med"
            else:
                return "low"
        
        try:
            all_content = ""
            entrypoints = []
            assets = []
            
            for artifact in artifacts:
                artifact_type = artifact.get("type", "text")
                source = artifact.get("source", "text")
                value = artifact.get("value", "")
                
                if len(value) > max_chars:
                    return ToolResult(success=False, result=None, error=f"Artifact exceeds max_chars limit ({max_chars})")
                
                if source == "repo_path":
                    repo_root = os.getenv("REPO_ROOT", "/Users/apple/github-projects/microdao-daarion")
                    abs_path = os.path.join(repo_root, value)
                    normalized = os.path.normpath(abs_path)
                    
                    if ".." in normalized or not normalized.startswith(repo_root):
                        return ToolResult(success=False, result=None, error="Path traversal blocked")
                    
                    try:
                        with open(normalized, "r", encoding="utf-8") as f:
                            value = f.read(max_chars)
                    except Exception as e:
                        return ToolResult(success=False, result=None, error=f"Cannot read file: {e}")
                
                all_content += value + "\n"
                
                if artifact_type == "openapi":
                    entrypoints.extend(extract_entrypoints_from_openapi(value))
                elif artifact_type == "diff":
                    entrypoints.extend(extract_entrypoints_from_diff(value))
                elif artifact_type == "text" or artifact_type == "dataflows":
                    entrypoints.extend(extract_entrypoints_from_diff(value))
            
            assets = extract_assets(all_content)
            trust_boundaries = get_default_trust_boundaries()
            
            threats = []
            
            for category, threat_list in STRIDE_THREATS.items():
                for threat in threat_list:
                    threats.append({
                        "id": f"TM-{category}-001",
                        "category": category,
                        "title": threat["title"],
                        "scenario": threat["scenario"],
                        "impact": "med",
                        "likelihood": "low",
                        "risk": "low",
                        "mitigations": threat["mitigations"],
                        "tests": threat["tests"]
                    })
            
            for pattern_name, threat_list in SPECIFIC_THREATS.items():
                if pattern_name in all_content or action == "analyze_diff":
                    for threat in threat_list:
                        threats.append({
                            "id": f"TM-{pattern_name}-001",
                            "category": "E",
                            "title": threat["title"],
                            "scenario": threat["scenario"],
                            "impact": "high",
                            "likelihood": "med",
                            "risk": "high",
                            "mitigations": threat["mitigations"],
                            "tests": threat["tests"]
                        })
            
            for ep in entrypoints:
                if ep.get("auth") == "none":
                    threats.append({
                        "id": "TM-AUTH-001",
                        "category": "S",
                        "title": "Unauthenticated endpoint",
                        "scenario": f"Endpoint {ep.get('id')} has no authentication",
                        "impact": "high",
                        "likelihood": "high",
                        "risk": "high",
                        "mitigations": ["Add authentication", "Use API keys or JWT"],
                        "tests": ["Auth bypass testing"]
                    })
            
            if risk_profile == "agentic_tools":
                threats.extend(AGENTIC_THREATS)
                trust_boundaries.append({"name": "agent_to_tool", "between": ["agent", "tool_endpoint"], "notes": "Tool execution boundary"})
            
            if risk_profile == "public_api":
                threats.extend(PUBLIC_API_THREATS)
                trust_boundaries.append({"name": "public_to_gateway", "between": ["public_internet", "gateway"], "notes": "Public API boundary"})
            
            for t in threats:
                t["risk"] = score_risk(t.get("impact", "low"), t.get("likelihood", "low"))
            
            controls = []
            control_map = {}
            for threat in threats:
                for mitigation in threat.get("mitigations", []):
                    if mitigation not in control_map:
                        control_map[mitigation] = {
                            "control": mitigation,
                            "priority": "p1" if threat.get("risk") == "high" else "p2",
                            "maps_to": [threat["id"]]
                        }
                    else:
                        control_map[mitigation]["maps_to"].append(threat["id"])
            
            controls = list(control_map.values())
            controls.sort(key=lambda x: (x["priority"], x["control"]))
            
            checklist = generate_checklist(assets, entrypoints, threats)
            checklist.sort(key=lambda x: (x["priority"], x["type"]))
            
            threat_count = len(threats)
            high_risk = len([t for t in threats if t.get("risk") == "high"])
            
            if strict and high_risk > 0:
                summary = f"⚠️ Threat model: {threat_count} threats, {high_risk} HIGH risk (strict mode: FAIL)"
            else:
                summary = f"✅ Threat model: {threat_count} threats, {high_risk} high risk"
            
            result = {
                "summary": summary,
                "scope": {
                    "service_name": service_name,
                    "risk_profile": risk_profile
                },
                "assets": assets[:50],
                "trust_boundaries": trust_boundaries,
                "entrypoints": entrypoints[:50],
                "threats": threats[:100],
                "controls": controls[:50],
                "security_checklist": checklist,
                "stats": {
                    "assets_count": len(assets),
                    "entrypoints_count": len(entrypoints),
                    "threats_count": threat_count,
                    "high_risk_count": high_risk,
                    "controls_count": len(controls),
                    "checklist_items": len(checklist)
                }
            }
            
            content_hash = hashlib.sha256(all_content.encode()).hexdigest()[:16]
            logger.info(f"Threat model: {service_name}, hash={content_hash}, threats={threat_count}, high={high_risk}")
            
            return ToolResult(success=True, result=result)
        
        except Exception as e:
            logger.error(f"Threat model error: {e}")
            return ToolResult(success=False, result=None, error=f"Internal error: {str(e)}")

    async def _job_orchestrator_tool(self, args: Dict) -> ToolResult:
        """
        Job Orchestrator Tool
        
        Executes controlled operational tasks from allowlisted registry.
        Supports: list_tasks, start_task, get_job, cancel_job.
        
        Security:
        - Only allowlisted tasks from registry
        - Input schema validation
        - Dry-run mode
        - RBAC entitlements
        - No arbitrary command execution
        """
        import re
        import os
        import json
        import hashlib
        import uuid
        from datetime import datetime
        import yaml
        
        action = (args or {}).get("action", "list_tasks")
        params = (args or {}).get("params", {})
        
        REPO_ROOT = os.getenv("REPO_ROOT", "/Users/apple/github-projects/microdao-daarion")
        TASK_REGISTRY_PATH = os.path.join(REPO_ROOT, "ops", "task_registry.yml")
        
        TASK_ENTITLEMENTS = {
            "deploy": "tools.jobs.run.deploy",
            "backup": "tools.jobs.run.backup",
            "smoke": "tools.jobs.run.smoke",
            "migrate": "tools.jobs.run.migrate",
            "drift": "tools.jobs.run.drift",
            "ops": "tools.jobs.run.ops",
        }
        
        def load_task_registry() -> dict:
            """Load task registry from YAML file"""
            if not os.path.exists(TASK_REGISTRY_PATH):
                return {"tasks": []}
            try:
                with open(TASK_REGISTRY_PATH, "r") as f:
                    return yaml.safe_load(f) or {"tasks": []}
            except Exception as e:
                logger.warning(f"Could not load task registry: {e}")
                return {"tasks": []}
        
        def check_entitlement(agent_id: str, entitlement: str) -> bool:
            """Check if agent has entitlement"""
            if agent_id in ["sofiia", "helion", "admin"]:
                return True
            return True
        
        def validate_inputs(task: dict, inputs: dict) -> tuple:
            """Validate inputs against schema"""
            schema = task.get("inputs_schema", {})
            if not schema:
                return True, None
            
            properties = schema.get("properties", {})
            required = schema.get("required", [])
            
            for req_field in required:
                if req_field not in inputs:
                    return False, f"Missing required field: {req_field}"
            
            for field, value in inputs.items():
                if field not in properties:
                    return False, f"Unknown field: {field}"
                
                field_schema = properties[field]
                field_type = field_schema.get("type")
                
                if field_type == "string" and "enum" in field_schema:
                    if value not in field_schema["enum"]:
                        return False, f"Invalid value for {field}: must be one of {field_schema['enum']}"
            
            return True, None
        
        def create_job_record(task_id: str, task: dict, inputs: dict, dry_run: bool, agent_id: str, idempotency_key: str = None) -> dict:
            """Create job record in memory (in real system would be DB)"""
            job_id = f"job-{uuid.uuid4().hex[:12]}"
            
            job = {
                "id": job_id,
                "task_id": task_id,
                "task_title": task.get("title", ""),
                "status": "queued" if not dry_run else "dry_run",
                "dry_run": dry_run,
                "inputs": inputs,
                "agent_id": agent_id,
                "created_at": datetime.utcnow().isoformat(),
                "started_at": None,
                "finished_at": None,
                "result": None,
                "stdout_trunc": "",
                "stderr_trunc": "",
                "idempotency_key": idempotency_key,
            }
            
            return job
        
        def get_task_by_id(task_id: str, registry: dict) -> tuple:
            """Find task by ID"""
            for task in registry.get("tasks", []):
                if task.get("id") == task_id:
                    return task, None
            return None, f"Task not found: {task_id}"
        
        def filter_tasks(registry: dict, filters: dict, agent_id: str) -> list:
            """Filter tasks by tags and agent entitlements"""
            filtered = []
            tag = filters.get("tag")
            service = filters.get("service")
            
            for task in registry.get("tasks", []):
                task_tags = task.get("tags", [])
                
                if tag and tag not in task_tags:
                    continue
                
                if service:
                    task_service = task.get("service", "")
                    if service.lower() not in task_service.lower():
                        continue
                
                entitlements_required = task.get("permissions", {}).get("entitlements_required", [])
                if entitlements_required:
                    has_entitlement = False
                    for ent in entitlements_required:
                        if check_entitlement(agent_id, ent):
                            has_entitlement = True
                            break
                    if not has_entitlement:
                        continue
                
                filtered.append(task)
            
            return filtered
        
        try:
            registry = load_task_registry()
            
            if action == "list_tasks":
                filters = params.get("filter", {})
                agent_id = args.get("agent_id", "unknown")
                
                tasks = filter_tasks(registry, filters, agent_id)
                
                task_list = []
                for task in tasks:
                    task_list.append({
                        "id": task.get("id"),
                        "title": task.get("title"),
                        "tags": task.get("tags", []),
                        "description": task.get("description", ""),
                        "timeout_sec": task.get("timeout_sec", 300),
                        "has_inputs_schema": bool(task.get("inputs_schema")),
                    })
                
                return ToolResult(success=True, result={
                    "tasks": task_list,
                    "count": len(task_list),
                    "filters": filters
                })
            
            elif action == "start_task":
                task_id = params.get("task_id")
                if not task_id:
                    return ToolResult(success=False, result=None, error="task_id is required")
                
                dry_run = params.get("dry_run", False)
                inputs = params.get("inputs", {})
                idempotency_key = params.get("idempotency_key")
                agent_id = args.get("agent_id", "unknown")
                
                task, error = get_task_by_id(task_id, registry)
                if error:
                    return ToolResult(success=False, result=None, error=error)
                
                entitlements_required = task.get("permissions", {}).get("entitlements_required", [])
                if entitlements_required:
                    has_entitlement = False
                    for ent in entitlements_required:
                        if check_entitlement(agent_id, ent):
                            has_entitlement = True
                            break
                    if not has_entitlement:
                        return ToolResult(success=False, result=None, error=f"Entitlement required: {entitlements_required}")
                
                valid, error = validate_inputs(task, inputs)
                if not valid:
                    return ToolResult(success=False, result=None, error=f"Input validation failed: {error}")
                
                # Internal runner: release_check
                runner = task.get("runner", "script")
                if runner == "internal" and task_id == "release_check":
                    if dry_run:
                        return ToolResult(success=True, result={
                            "message": "Dry run – release_check would run all gates",
                            "gates": ["pr_review", "config_lint", "contract_diff", "threat_model"],
                            "inputs": inputs,
                        })
                    try:
                        from release_check_runner import run_release_check
                        report = await run_release_check(self, inputs, agent_id)
                        return ToolResult(success=True, result=report)
                    except Exception as rce:
                        logger.exception("release_check runner failed")
                        return ToolResult(success=False, result=None, error=f"release_check failed: {rce}")

                command_ref = task.get("command_ref")
                if command_ref:
                    abs_path = os.path.join(REPO_ROOT, command_ref)
                    normalized = os.path.normpath(abs_path)
                    
                    if ".." in normalized or not normalized.startswith(REPO_ROOT):
                        return ToolResult(success=False, result=None, error="Invalid command_ref path")
                    
                    if not os.path.exists(normalized):
                        return ToolResult(success=False, result=None, error=f"Command not found: {command_ref}")
                
                job = create_job_record(task_id, task, inputs, dry_run, agent_id, idempotency_key)
                
                if dry_run:
                    execution_plan = {
                        "task_id": task_id,
                        "task_title": task.get("title"),
                        "command_ref": command_ref,
                        "inputs": inputs,
                        "timeout_sec": task.get("timeout_sec", 300),
                        "dry_run_behavior": task.get("dry_run_behavior", "validation_only"),
                        "would_execute": not dry_run,
                    }
                    
                    return ToolResult(success=True, result={
                        "job": job,
                        "execution_plan": execution_plan,
                        "message": "Dry run - no execution performed"
                    })
                
                return ToolResult(success=True, result={
                    "job": job,
                    "message": f"Job {job['id']} queued for execution"
                })
            
            elif action == "get_job":
                job_id = params.get("job_id")
                if not job_id:
                    return ToolResult(success=False, result=None, error="job_id is required")
                
                return ToolResult(success=True, result={
                    "job_id": job_id,
                    "status": "unknown",
                    "message": "Job tracking requires database integration"
                })
            
            elif action == "cancel_job":
                job_id = params.get("job_id")
                if not job_id:
                    return ToolResult(success=False, result=None, error="job_id is required")
                
                reason = params.get("reason", "No reason provided")
                agent_id = args.get("agent_id", "unknown")
                
                if agent_id not in ["sofiia", "helion", "admin"]:
                    return ToolResult(success=False, result=None, error="Only sofiia/helion/admin can cancel jobs")
                
                return ToolResult(success=True, result={
                    "job_id": job_id,
                    "status": "canceled",
                    "reason": reason,
                    "canceled_by": agent_id,
                    "canceled_at": datetime.utcnow().isoformat()
                })
            
            else:
                return ToolResult(success=False, result=None, error=f"Unknown action: {action}")
        
        except Exception as e:
            logger.error(f"Job orchestrator error: {e}")
            return ToolResult(success=False, result=None, error=f"Internal error: {str(e)}")
