NATS wildcards (node.*.capabilities.get) only work for subscriptions, not for publish. Switch to a dedicated broadcast subject (fabric.capabilities.discover) that all NCS instances subscribe to, enabling proper scatter-gather discovery across nodes. Made-with: Cursor
9173 lines
423 KiB
Python
9173 lines
423 KiB
Python
"""
|
||
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 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 ринкові дані: поточну ціну, котирування, обсяги, аналітичні фічі (VWAP, spread, volatility, trade signals). Доступні символи: BTCUSDT, ETHUSDT.",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"symbol": {
|
||
"type": "string",
|
||
"description": "Торговий символ (наприклад BTCUSDT, ETHUSDT)"
|
||
},
|
||
"query_type": {
|
||
"type": "string",
|
||
"enum": ["price", "features", "all"],
|
||
"description": "Тип запиту: price (ціна + котирування), features (аналітичні фічі), all (все разом)",
|
||
"default": "all"
|
||
}
|
||
},
|
||
"required": ["symbol"]
|
||
}
|
||
}
|
||
},
|
||
# 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"]
|
||
}
|
||
}
|
||
}
|
||
]
|
||
|
||
|
||
@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.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.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)
|
||
# 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)
|
||
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)}",
|
||
)
|
||
|
||
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 entrypoint routed to Comfy (NODE3)."""
|
||
if not args.get("prompt"):
|
||
return ToolResult(success=False, result=None, error="prompt is required")
|
||
|
||
comfy_args = dict(args)
|
||
comfy_args.setdefault("negative_prompt", "blurry, low quality, watermark")
|
||
comfy_args.setdefault("steps", 28)
|
||
comfy_args.setdefault("timeout_s", 180)
|
||
return await self._comfy_generate_image(comfy_args)
|
||
|
||
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,
|
||
"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 from Market Data Service and SenpAI MD Consumer."""
|
||
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")
|
||
|
||
results: Dict[str, Any] = {}
|
||
|
||
try:
|
||
async with httpx.AsyncClient(timeout=8.0) as client:
|
||
if query_type in ("price", "all"):
|
||
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"),
|
||
"size": trade.get("size"),
|
||
"side": trade.get("side"),
|
||
"bid": bid,
|
||
"ask": ask,
|
||
"spread": spread,
|
||
"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 query_type in ("features", "all"):
|
||
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),
|
||
"trade_volume_10s": round(float(feats.get("trade_volume_10s", 0) or 0), 4),
|
||
"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),
|
||
"latency_p50_ms": round(float(feats.get("latency_ms_p50", 0) or 0), 1),
|
||
"latency_p95_ms": round(float(feats.get("latency_ms_p95", 0) or 0), 1),
|
||
}
|
||
else:
|
||
results["features_error"] = f"senpai-consumer status={resp.status_code}"
|
||
except Exception as e:
|
||
results["features_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 _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)}")
|
||
|
||
async def _kb_tool(self, args: Dict) -> ToolResult:
|
||
"""
|
||
Knowledge Base Tool
|
||
|
||
Provides read-only access to organizational knowledge:
|
||
- ADR, architecture docs, runbooks, standards
|
||
- Search with ranking
|
||
- Snippets with context
|
||
- File opening with line ranges
|
||
|
||
Security:
|
||
- Only allowlisted directories
|
||
- Path traversal protection
|
||
- Secrets redaction
|
||
- Read-only
|
||
"""
|
||
import os
|
||
import re
|
||
import fnmatch
|
||
from pathlib import Path
|
||
|
||
action = (args or {}).get("action", "search")
|
||
params = (args or {}).get("params", {})
|
||
|
||
REPO_ROOT = os.getenv("REPO_ROOT", "/Users/apple/github-projects/microdao-daarion")
|
||
|
||
ALLOWED_KB_PATHS = [
|
||
"docs",
|
||
"runbooks",
|
||
"ops",
|
||
"adr",
|
||
"specs",
|
||
]
|
||
|
||
EXCLUDED_DIRS = {
|
||
"node_modules", "vendor", "dist", "build", ".git",
|
||
"__pycache__", ".pytest_cache", "venv", ".venv"
|
||
}
|
||
|
||
SECRET_PATTERN = re.compile(
|
||
r'(?i)(api[_-]?key|secret|token|password|jwt|bearer)\s*[:=]\s*["\']?([a-zA-Z0-9_\-]{8,})'
|
||
)
|
||
|
||
def is_allowed_path(path: str) -> bool:
|
||
"""Check if path is within allowed directories"""
|
||
normalized = os.path.normpath(path)
|
||
for allowed in ALLOWED_KB_PATHS:
|
||
if normalized.startswith(os.path.join(REPO_ROOT, allowed)):
|
||
return True
|
||
return False
|
||
|
||
def is_excluded(path: str) -> bool:
|
||
"""Check if path is in excluded directories"""
|
||
parts = Path(path).parts
|
||
return any(excluded in parts for excluded in EXCLUDED_DIRS)
|
||
|
||
def redact_secrets(text: str) -> str:
|
||
"""Redact secrets from text"""
|
||
def replacer(match):
|
||
key = match.group(1)
|
||
value = match.group(2) if match.lastindex >= 2 else ""
|
||
return f"{key}=***{'*' * min(len(value), 8)}"
|
||
return SECRET_PATTERN.sub(replacer, text)
|
||
|
||
def tokenize(query: str) -> set:
|
||
"""Simple tokenization"""
|
||
words = re.findall(r'\b\w+\b', query.lower())
|
||
return set(words)
|
||
|
||
def calculate_score(content: str, query_tokens: set, file_path: str) -> float:
|
||
"""Calculate relevance score"""
|
||
content_lower = content.lower()
|
||
lines = content.split('\n')
|
||
|
||
score = 0.0
|
||
|
||
for token in query_tokens:
|
||
count = content_lower.count(token)
|
||
score += count
|
||
|
||
if query_tokens:
|
||
header_bonus = 0
|
||
for i, line in enumerate(lines[:10]):
|
||
if line.strip().startswith('#'):
|
||
if any(tok in line.lower() for tok in query_tokens):
|
||
header_bonus += 5
|
||
score += header_bonus
|
||
|
||
if 'adr' in file_path.lower() or 'adr/' in file_path.lower():
|
||
score *= 1.2
|
||
|
||
filename_bonus = sum(2 for token in query_tokens if token in Path(file_path).name.lower())
|
||
score += filename_bonus
|
||
|
||
if len(content) > 50000:
|
||
score *= 0.8
|
||
|
||
return score
|
||
|
||
def search_files(query: str, paths: list, file_glob: str, limit: int) -> list:
|
||
"""Search files for query"""
|
||
query_tokens = tokenize(query)
|
||
results = []
|
||
|
||
search_paths = paths if paths else ALLOWED_KB_PATHS
|
||
|
||
for search_dir in search_paths:
|
||
full_path = os.path.join(REPO_ROOT, search_dir)
|
||
|
||
if not os.path.exists(full_path):
|
||
continue
|
||
|
||
for root, dirs, files in os.walk(full_path):
|
||
dirs[:] = [d for d in dirs if not is_excluded(os.path.join(root, d))]
|
||
|
||
for filename in files:
|
||
if file_glob and not fnmatch.fnmatch(filename, file_glob):
|
||
continue
|
||
|
||
if not filename.endswith(('.md', '.txt', '.yaml', '.yml', '.json')):
|
||
continue
|
||
|
||
file_path = os.path.join(root, filename)
|
||
|
||
if is_excluded(file_path):
|
||
continue
|
||
|
||
try:
|
||
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||
content = f.read(100000)
|
||
|
||
score = calculate_score(content, query_tokens, file_path)
|
||
|
||
if score > 0:
|
||
rel_path = os.path.relpath(file_path, REPO_ROOT)
|
||
results.append({
|
||
"path": rel_path,
|
||
"score": score,
|
||
"content": content
|
||
})
|
||
except Exception:
|
||
continue
|
||
|
||
results.sort(key=lambda x: x["score"], reverse=True)
|
||
return results[:limit]
|
||
|
||
def extract_snippets(content: str, query: str, context_lines: int, max_chars: int) -> list:
|
||
"""Extract snippets around matches"""
|
||
query_tokens = tokenize(query)
|
||
lines = content.split('\n')
|
||
snippets = []
|
||
|
||
for i, line in enumerate(lines):
|
||
line_lower = line.lower()
|
||
if any(token in line_lower for token in query_tokens):
|
||
start = max(0, i - context_lines)
|
||
end = min(len(lines), i + context_lines + 1)
|
||
|
||
snippet_lines = lines[start:end]
|
||
snippet_text = '\n'.join(snippet_lines)
|
||
snippet_text = redact_secrets(snippet_text)
|
||
|
||
if len(snippet_text) > max_chars:
|
||
snippet_text = snippet_text[:max_chars] + "..."
|
||
|
||
snippets.append({
|
||
"lines": f"L{start+1}-L{end}",
|
||
"text": snippet_text
|
||
})
|
||
|
||
if len(snippets) >= 10:
|
||
break
|
||
|
||
return snippets
|
||
|
||
def highlight_matches(text: str, query: str) -> list:
|
||
"""Generate highlight strings"""
|
||
query_tokens = tokenize(query)
|
||
highlights = []
|
||
|
||
for token in query_tokens:
|
||
pattern = re.compile(re.escape(token), re.IGNORECASE)
|
||
for match in pattern.finditer(text):
|
||
start = max(0, match.start() - 30)
|
||
end = min(len(text), match.end() + 30)
|
||
snippet = "..." + text[start:end] + "..."
|
||
highlights.append(snippet)
|
||
break
|
||
|
||
return highlights[:5]
|
||
|
||
try:
|
||
if action == "search":
|
||
query = params.get("query", "")
|
||
if not query:
|
||
return ToolResult(success=False, result=None, error="query is required for search")
|
||
|
||
paths = params.get("paths", [])
|
||
file_glob = params.get("file_glob", "**/*.md")
|
||
limit = params.get("limit", 20)
|
||
|
||
results = search_files(query, paths, file_glob, limit)
|
||
|
||
search_results = []
|
||
for r in results:
|
||
highlights = highlight_matches(r["content"], query)
|
||
rel_path = r["path"]
|
||
search_results.append({
|
||
"path": rel_path,
|
||
"score": round(r["score"], 2),
|
||
"highlights": highlights
|
||
})
|
||
|
||
return ToolResult(success=True, result={
|
||
"summary": f"Found {len(search_results)} results for '{query}'",
|
||
"results": search_results,
|
||
"query": query,
|
||
"count": len(search_results)
|
||
})
|
||
|
||
elif action == "snippets":
|
||
query = params.get("query", "")
|
||
if not query:
|
||
return ToolResult(success=False, result=None, error="query is required for snippets")
|
||
|
||
paths = params.get("paths", [])
|
||
limit = params.get("limit", 8)
|
||
context_lines = params.get("context_lines", 4)
|
||
max_chars = params.get("max_chars_per_snippet", 800)
|
||
|
||
search_results = search_files(query, paths, "**/*.md", limit * 2)
|
||
|
||
all_snippets = []
|
||
for r in search_results[:limit]:
|
||
snippets = extract_snippets(r["content"], query, context_lines, max_chars)
|
||
for snippet in snippets:
|
||
all_snippets.append({
|
||
"path": r["path"],
|
||
"score": round(r["score"], 2),
|
||
"lines": snippet["lines"],
|
||
"text": snippet["text"]
|
||
})
|
||
|
||
if len(all_snippets) >= limit * 3:
|
||
break
|
||
|
||
all_snippets.sort(key=lambda x: x["score"], reverse=True)
|
||
all_snippets = all_snippets[:limit]
|
||
|
||
return ToolResult(success=True, result={
|
||
"summary": f"Found {len(all_snippets)} snippets for '{query}'",
|
||
"results": all_snippets,
|
||
"query": query,
|
||
"count": len(all_snippets)
|
||
})
|
||
|
||
elif action == "open":
|
||
file_path = params.get("path")
|
||
if not file_path:
|
||
return ToolResult(success=False, result=None, error="path is required")
|
||
|
||
full_path = os.path.join(REPO_ROOT, file_path)
|
||
normalized = os.path.normpath(full_path)
|
||
|
||
if ".." in normalized or not normalized.startswith(REPO_ROOT):
|
||
return ToolResult(success=False, result=None, error="Path traversal blocked")
|
||
|
||
if not is_allowed_path(normalized):
|
||
return ToolResult(success=False, result=None, error="Path not in allowed directories")
|
||
|
||
if not os.path.exists(normalized):
|
||
return ToolResult(success=False, result=None, error="File not found")
|
||
|
||
start_line = params.get("start_line", 1)
|
||
end_line = params.get("end_line")
|
||
max_bytes = params.get("max_bytes", 200000)
|
||
|
||
try:
|
||
with open(normalized, 'r', encoding='utf-8', errors='ignore') as f:
|
||
content = f.read(max_bytes)
|
||
|
||
lines = content.split('\n')
|
||
|
||
if end_line:
|
||
lines = lines[start_line-1:end_line]
|
||
else:
|
||
lines = lines[start_line-1:]
|
||
|
||
content = '\n'.join(lines)
|
||
content = redact_secrets(content)
|
||
|
||
return ToolResult(success=True, result={
|
||
"path": file_path,
|
||
"start_line": start_line,
|
||
"end_line": end_line or len(lines) + start_line - 1,
|
||
"total_lines": len(lines),
|
||
"content": content,
|
||
"truncated": len(content) >= max_bytes
|
||
})
|
||
except Exception as e:
|
||
return ToolResult(success=False, result=None, error=f"Cannot read file: {str(e)}")
|
||
|
||
elif action == "sources":
|
||
paths = params.get("paths", ALLOWED_KB_PATHS)
|
||
|
||
indexed_sources = []
|
||
for search_dir in paths:
|
||
full_path = os.path.join(REPO_ROOT, search_dir)
|
||
|
||
if not os.path.exists(full_path):
|
||
continue
|
||
|
||
file_count = 0
|
||
for root, dirs, files in os.walk(full_path):
|
||
dirs[:] = [d for d in dirs if not is_excluded(os.path.join(root, d))]
|
||
for f in files:
|
||
if f.endswith(('.md', '.txt', '.yaml', '.yml', '.json')):
|
||
file_count += 1
|
||
|
||
indexed_sources.append({
|
||
"path": search_dir,
|
||
"file_count": file_count,
|
||
"status": "indexed"
|
||
})
|
||
|
||
return ToolResult(success=True, result={
|
||
"summary": f"Found {len(indexed_sources)} indexed sources",
|
||
"sources": indexed_sources,
|
||
"allowed_paths": ALLOWED_KB_PATHS
|
||
})
|
||
|
||
else:
|
||
return ToolResult(success=False, result=None, error=f"Unknown action: {action}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"KB tool error: {e}")
|
||
return ToolResult(success=False, result=None, error=f"Internal error: {str(e)}")
|