""" Tool Manager for Helion Agent Implements OpenAI-compatible function calling for DeepSeek, Mistral, Grok """ import os import asyncio import uuid from agent_tools_config import get_agent_tools, is_tool_allowed import json import logging import hashlib import base64 import csv import re import tempfile import subprocess import httpx from typing import Dict, List, Any, Optional from dataclasses import dataclass from io import BytesIO, StringIO from pathlib import Path, PurePath import xml.etree.ElementTree as ET from xml.sax.saxutils import escape as xml_escape from zipfile import ZIP_DEFLATED, ZipFile logger = logging.getLogger(__name__) # Tool definitions in OpenAI function calling format # ORDER MATTERS: Memory/Graph tools first, then web search as fallback TOOL_DEFINITIONS = [ # PRIORITY 1: Internal knowledge sources (use FIRST) { "type": "function", "function": { "name": "memory_search", "description": "🔍 ПЕРШИЙ КРОК для пошуку! Шукає в моїй пам'яті: збережені факти, документи, розмови. ЗАВЖДИ використовуй спочатку перед web_search!", "parameters": { "type": "object", "properties": { "query": { "type": "string", "description": "Що шукати в пам'яті" } }, "required": ["query"] } } }, { "type": "function", "function": { "name": "graph_query", "description": "🔍 Пошук в Knowledge Graph - зв'язки між проєктами, людьми, темами Energy Union. Використовуй для питань про проєкти, партнерів, технології.", "parameters": { "type": "object", "properties": { "query": { "type": "string", "description": "Що шукати (назва проєкту, людини, теми)" }, "entity_type": { "type": "string", "enum": ["User", "Topic", "Project", "Fact"], "description": "Тип сутності для пошуку" } }, "required": ["query"] } } }, # PRIORITY 2: Web search (use ONLY if memory/graph don't have info) { "type": "function", "function": { "name": "web_search", "description": "🌐 Пошук в інтернеті. Використовуй ТІЛЬКИ якщо memory_search і graph_query не знайшли потрібної інформації!", "parameters": { "type": "object", "properties": { "query": { "type": "string", "description": "Пошуковий запит" }, "max_results": { "type": "integer", "description": "Максимальна кількість результатів (1-10)", "default": 5 } }, "required": ["query"] } } }, { "type": "function", "function": { "name": "web_extract", "description": "Витягнути текстовий контент з веб-сторінки за URL", "parameters": { "type": "object", "properties": { "url": { "type": "string", "description": "URL сторінки для читання" } }, "required": ["url"] } } }, # PRIORITY 3: Generation tools { "type": "function", "function": { "name": "image_generate", "description": "🎨 Згенерувати зображення за текстовим описом (FLUX)", "parameters": { "type": "object", "properties": { "prompt": { "type": "string", "description": "Опис зображення для генерації (англійською краще)" }, "width": { "type": "integer", "description": "Ширина зображення", "default": 512 }, "height": { "type": "integer", "description": "Висота зображення", "default": 512 } }, "required": ["prompt"] } } }, { "type": "function", "function": { "name": "comfy_generate_image", "description": "🖼️ Згенерувати зображення через ComfyUI (NODE3, Stable Diffusion). Для високої якості та детальних зображень.", "parameters": { "type": "object", "properties": { "prompt": { "type": "string", "description": "Детальний опис зображення (англійською)" }, "negative_prompt": { "type": "string", "description": "Що НЕ включати в зображення", "default": "blurry, low quality, watermark" }, "width": { "type": "integer", "description": "Ширина (512, 768, 1024)", "default": 512 }, "height": { "type": "integer", "description": "Висота (512, 768, 1024)", "default": 512 }, "steps": { "type": "integer", "description": "Кількість кроків генерації (20-50)", "default": 28 } }, "required": ["prompt"] } } }, { "type": "function", "function": { "name": "comfy_generate_video", "description": "🎬 Згенерувати відео через ComfyUI (NODE3, LTX-2). Text-to-video для коротких кліпів.", "parameters": { "type": "object", "properties": { "prompt": { "type": "string", "description": "Детальний опис відео (англійською)" }, "seconds": { "type": "integer", "description": "Тривалість в секундах (2-8)", "default": 4 }, "fps": { "type": "integer", "description": "Кадри в секунду (24-30)", "default": 24 }, "steps": { "type": "integer", "description": "Кількість кроків генерації (20-40)", "default": 30 } }, "required": ["prompt"] } } }, { "type": "function", "function": { "name": "remember_fact", "description": "Запам'ятати важливий факт про користувача або тему", "parameters": { "type": "object", "properties": { "fact": { "type": "string", "description": "Факт для запам'ятовування" }, "about": { "type": "string", "description": "Про кого/що цей факт (username або тема)" }, "category": { "type": "string", "enum": ["personal", "technical", "preference", "project"], "description": "Категорія факту" } }, "required": ["fact"] } } }, # PRIORITY 4: Document/Presentation tools { "type": "function", "function": { "name": "presentation_create", "description": "📊 Створити презентацію PowerPoint. Використовуй коли користувач просить 'створи презентацію', 'зроби презентацію', 'підготуй слайди'.", "parameters": { "type": "object", "properties": { "title": { "type": "string", "description": "Назва презентації" }, "slides": { "type": "array", "items": { "type": "object", "properties": { "title": {"type": "string", "description": "Заголовок слайду"}, "content": {"type": "string", "description": "Контент слайду (markdown)"} } }, "description": "Масив слайдів: [{title, content}]" }, "brand_id": { "type": "string", "description": "ID бренду для стилю (energyunion, greenfood, nutra)", "default": "energyunion" }, "theme_version": { "type": "string", "description": "Версія теми", "default": "v1.0.0" }, "language": { "type": "string", "enum": ["uk", "en", "ru"], "description": "Мова презентації", "default": "uk" } }, "required": ["title", "slides"] } } }, { "type": "function", "function": { "name": "presentation_status", "description": "📋 Перевірити статус створення презентації за job_id", "parameters": { "type": "object", "properties": { "job_id": { "type": "string", "description": "ID завдання рендерингу" } }, "required": ["job_id"] } } }, { "type": "function", "function": { "name": "presentation_download", "description": "📥 Отримати посилання на готову презентацію за artifact_id", "parameters": { "type": "object", "properties": { "artifact_id": { "type": "string", "description": "ID артефакту презентації" }, "format": { "type": "string", "enum": ["pptx", "pdf"], "description": "Формат файлу", "default": "pptx" } }, "required": ["artifact_id"] } } }, { "type": "function", "function": { "name": "file_tool", "description": "📁 Універсальний file tool для створення та оновлення CSV/JSON/YAML/ZIP і інших форматів через action-based API.", "parameters": { "type": "object", "properties": { "action": { "type": "string", "enum": [ "excel_create", "excel_update", "docx_create", "docx_update", "pptx_create", "pptx_update", "ods_create", "ods_update", "parquet_create", "parquet_update", "csv_create", "csv_update", "pdf_fill", "pdf_merge", "pdf_split", "pdf_update", "djvu_to_pdf", "djvu_extract_text", "json_export", "yaml_export", "zip_bundle", "text_create", "text_update", "markdown_create", "markdown_update", "xml_export", "html_export", "image_create", "image_edit", "image_convert", "image_bundle", "svg_export", "svg_to_png" ], "description": "Дія file tool" }, "file_name": { "type": "string", "description": "Назва файлу-результату" }, "file_base64": { "type": "string", "description": "Вхідний файл у base64 для update-операцій" }, "content": { "description": "Контент для json/yaml export" }, "headers": { "type": "array", "items": {"type": "string"}, "description": "Заголовки для CSV" }, "rows": { "type": "array", "description": "Рядки для CSV" }, "entries": { "type": "array", "description": "Елементи для zip_bundle" }, "operation": { "type": "string", "enum": ["append", "replace"], "description": "Режим csv_update" } }, "required": ["action"] } } }, # PRIORITY 5: Web Scraping tools { "type": "function", "function": { "name": "crawl4ai_scrape", "description": "🕷️ Глибокий скрейпінг веб-сторінки через Crawl4AI. Витягує повний контент, структуровані дані, медіа. Використовуй для детального аналізу сайтів.", "parameters": { "type": "object", "properties": { "url": { "type": "string", "description": "URL сторінки для скрейпінгу" }, "extract_links": { "type": "boolean", "description": "Витягувати посилання зі сторінки", "default": True }, "extract_images": { "type": "boolean", "description": "Витягувати зображення", "default": False } }, "required": ["url"] } } }, # PRIORITY 6: TTS tools { "type": "function", "function": { "name": "tts_speak", "description": "🔊 Перетворити текст на аудіо (Text-to-Speech). Повертає аудіо файл. Використовуй коли користувач просить озвучити текст.", "parameters": { "type": "object", "properties": { "text": { "type": "string", "description": "Текст для озвучення" }, "language": { "type": "string", "enum": ["uk", "en", "ru"], "description": "Мова озвучення", "default": "uk" } }, "required": ["text"] } } }, # PRIORITY 7: Market Data tools (SenpAI) { "type": "function", "function": { "name": "market_data", "description": "📊 Real-time ринкові дані: ціна, стакан, обсяги, 24h статистика, аналітичні фічі. Підтримувані символи (23): BTC, ETH, BNB, SOL, XRP, ADA, DOGE, AVAX, DOT, LINK, POL(MATIC), SHIB, TRX, UNI, LTC, ATOM, NEAR, ICP, FIL, APT (Binance Spot), PAXGUSDT (Gold-backed), XAUUSDT (Gold via Kraken), XAGUSDT (Silver via Kraken).", "parameters": { "type": "object", "properties": { "symbol": { "type": "string", "description": "Символ пари: BTCUSDT, ETHUSDT, SOLUSDT, XAUUSDT, XAGUSDT, PAXGUSDT тощо. Підтримується 23 символи." }, "query_type": { "type": "string", "enum": ["price", "features", "all", "multi"], "description": "price=ціна+стакан, features=VWAP/volatility/signals, all=повний, multi=всі 23 символи одразу", "default": "all" } }, "required": ["symbol"] } } }, # PRIORITY 7b: Binance Bot Monitor (SenpAI) { "type": "function", "function": { "name": "binance_bots_top", "description": "🤖 Показати топ торгових ботів Binance Marketplace (Spot/Futures Grid): ROI, PNL, риск-рейтинг. Також показує результати web-пошуку по Binance ботах.", "parameters": { "type": "object", "properties": { "grid_type": { "type": "string", "enum": ["SPOT", "FUTURES"], "description": "Тип гриду: SPOT або FUTURES", "default": "SPOT" }, "limit": { "type": "integer", "description": "Кількість ботів (max 20)", "default": 10 } }, "required": [] } } }, { "type": "function", "function": { "name": "binance_account_bots", "description": "💼 Показати стан власного Binance суб-акаунту: баланси, активні grid/algo ордери, типи дозволів (canTrade, permissions).", "parameters": { "type": "object", "properties": { "force_refresh": { "type": "boolean", "description": "Примусово оновити дані з Binance API (ігноруючи кеш)", "default": False } }, "required": [] } } }, # PRIORITY 8: 1OK Window Master tools { "type": "function", "function": { "name": "crm_search_client", "description": "Пошук клієнта в CRM за телефоном/email/ПІБ.", "parameters": { "type": "object", "properties": { "query": {"type": "string", "description": "Телефон, email або ім'я клієнта"} }, "required": ["query"] } } }, { "type": "function", "function": { "name": "crm_upsert_client", "description": "Створити або оновити клієнта в CRM.", "parameters": { "type": "object", "properties": { "client_payload": {"type": "object", "description": "Дані клієнта"} }, "required": ["client_payload"] } } }, { "type": "function", "function": { "name": "crm_upsert_site", "description": "Створити або оновити об'єкт (адресу) в CRM.", "parameters": { "type": "object", "properties": { "site_payload": {"type": "object", "description": "Дані об'єкта/адреси"} }, "required": ["site_payload"] } } }, { "type": "function", "function": { "name": "crm_upsert_window_unit", "description": "Створити або оновити віконний блок/проріз в CRM.", "parameters": { "type": "object", "properties": { "window_payload": {"type": "object", "description": "Дані віконного блоку"} }, "required": ["window_payload"] } } }, { "type": "function", "function": { "name": "crm_create_quote", "description": "Створити quote/КП в CRM.", "parameters": { "type": "object", "properties": { "quote_payload": {"type": "object", "description": "Дані КП/розрахунку"} }, "required": ["quote_payload"] } } }, { "type": "function", "function": { "name": "crm_update_quote", "description": "Оновити існуючий quote в CRM.", "parameters": { "type": "object", "properties": { "quote_id": {"type": "string"}, "patch": {"type": "object"} }, "required": ["quote_id", "patch"] } } }, { "type": "function", "function": { "name": "crm_create_job", "description": "Створити job (замір/монтаж/сервіс) в CRM.", "parameters": { "type": "object", "properties": { "job_payload": {"type": "object", "description": "Дані job"} }, "required": ["job_payload"] } } }, { "type": "function", "function": { "name": "calc_window_quote", "description": "Прорахунок вікон через calc-сервіс.", "parameters": { "type": "object", "properties": { "input_payload": {"type": "object", "description": "Вхід для калькулятора"} }, "required": ["input_payload"] } } }, { "type": "function", "function": { "name": "docs_render_quote_pdf", "description": "Рендер PDF комерційної пропозиції.", "parameters": { "type": "object", "properties": { "quote_id": {"type": "string"}, "quote_payload": {"type": "object"} } } } }, { "type": "function", "function": { "name": "docs_render_invoice_pdf", "description": "Рендер PDF рахунку.", "parameters": { "type": "object", "properties": { "invoice_payload": {"type": "object", "description": "Дані рахунку"} }, "required": ["invoice_payload"] } } }, { "type": "function", "function": { "name": "schedule_propose_slots", "description": "Запропонувати слоти на замір/монтаж.", "parameters": { "type": "object", "properties": { "params": {"type": "object", "description": "Параметри планування"} }, "required": ["params"] } } }, { "type": "function", "function": { "name": "schedule_confirm_slot", "description": "Підтвердити обраний слот.", "parameters": { "type": "object", "properties": { "job_id": {"type": "string"}, "slot": {} }, "required": ["job_id", "slot"] } } }, # PRIORITY 9+: Data Governance & Privacy Tool { "type": "function", "function": { "name": "data_governance_tool", "description": "🔒 Data Governance & Privacy: детермінований сканер PII/secrets/logging-ризиків і retention-політик. Actions: digest_audit (щоденний зріз+markdown), scan_repo, scan_audit, retention_check, policy. Read-only, evidence завжди маскується.", "parameters": { "type": "object", "properties": { "action": { "type": "string", "enum": ["digest_audit", "scan_repo", "scan_audit", "retention_check", "policy"], "description": "digest_audit=щоденний зріз+markdown, scan_repo=статичний аналіз файлів, scan_audit=аналіз audit-стріму, retention_check=перевірка cleanup-механізмів, policy=поточні політики" }, "backend": { "type": "string", "enum": ["auto", "jsonl", "postgres"], "description": "Джерело для scan_audit/digest_audit: auto=Postgres або JSONL (default)" }, "mode": { "type": "string", "enum": ["fast", "full"], "description": "fast=тільки .py/.yml/.json (default), full=всі розширення" }, "max_files": {"type": "integer", "description": "Максимум файлів для scan_repo (default 200)"}, "paths_include": { "type": "array", "items": {"type": "string"}, "description": "Директорії для сканування (default: services/, config/, ops/)" }, "paths_exclude": { "type": "array", "items": {"type": "string"}, "description": "Шаблони виключень (glob)" }, "focus": { "type": "array", "items": {"type": "string", "enum": ["pii", "secrets", "logging", "retention"]}, "description": "Категорії перевірок (default: всі)" }, "backend": { "type": "string", "enum": ["jsonl", "postgres"], "description": "Backend для scan_audit (default: jsonl)" }, "time_window_hours": {"type": "integer", "description": "Вікно для scan_audit в годинах (default 24)"}, "max_events": {"type": "integer", "description": "Ліміт подій для scan_audit (default 50000)"} }, "required": ["action"] } } }, # PRIORITY 9+: Cost Analyzer Tool (FinOps) { "type": "function", "function": { "name": "cost_analyzer_tool", "description": "📊 FinOps: аналіз витрат/навантаження по tools/агентах/сервісах. Actions: digest (щоденний зріз), report (агрегація), top (топ-N), anomalies (спайки), weights (конфіг). backend=auto вибирає Postgres або JSONL.", "parameters": { "type": "object", "properties": { "action": { "type": "string", "enum": ["digest", "report", "top", "anomalies", "weights"], "description": "digest=щоденний зріз+markdown, report=агрегація, top=топ-N, anomalies=спайки, weights=ваги" }, "backend": { "type": "string", "enum": ["auto", "jsonl", "postgres"], "description": "Джерело даних: auto=Postgres або JSONL (default)" }, "time_range": { "type": "object", "properties": { "from": {"type": "string", "description": "ISO8601 start"}, "to": {"type": "string", "description": "ISO8601 end"} }, "description": "Часовий діапазон (для report)" }, "group_by": { "type": "array", "items": {"type": "string", "enum": ["tool", "agent_id", "user_id", "workspace_id"]}, "description": "Ключі агрегації (для report)" }, "top_n": {"type": "integer", "description": "Кількість топ елементів (default 10)"}, "window_hours": {"type": "integer", "description": "Вікно аналізу в годинах (для top/anomalies)"}, "window_minutes": {"type": "integer", "description": "Вікно пошуку спайків в хвилинах (для anomalies)"}, "baseline_hours": {"type": "integer", "description": "Базовий період порівняння в годинах (для anomalies)"}, "ratio_threshold": {"type": "number", "description": "Поріг спайку (default 3.0)"}, "include_failed": {"type": "boolean", "description": "Включати failed calls (default true)"}, "include_hourly": {"type": "boolean", "description": "Включати погодинний тренд (default false)"} }, "required": ["action"] } } }, # PRIORITY 9+: Dependency Scanner Tool { "type": "function", "function": { "name": "dependency_scanner_tool", "description": "🔒 Сканує залежності Python/Node.js на вразливості (OSV.dev), застарілі версії та license policy. Offline-friendly: використовує локальний кеш. Повертає pass/fail + структурований звіт.", "parameters": { "type": "object", "properties": { "action": { "type": "string", "enum": ["scan"], "description": "Дія: scan (запустити сканування)" }, "targets": { "type": "array", "items": {"type": "string", "enum": ["python", "node"]}, "description": "Екосистеми для сканування (default: python та node)" }, "vuln_mode": { "type": "string", "enum": ["online", "offline_cache"], "description": "Режим перевірки вразливостей (default: offline_cache)" }, "fail_on": { "type": "array", "items": {"type": "string", "enum": ["CRITICAL", "HIGH", "MEDIUM", "LOW"]}, "description": "Severity рівні, що блокують (default: CRITICAL, HIGH)" }, "timeout_sec": { "type": "number", "description": "Таймаут в секундах (default: 40)" } }, "required": ["action"] } } }, # PRIORITY 9+: Drift Analyzer Tool { "type": "function", "function": { "name": "drift_analyzer_tool", "description": "🔍 Аналіз drift між 'джерелами правди' і фактичним станом репозиторію: services (catalog vs compose), OpenAPI (spec vs code routes), NATS (inventory vs code), tools (rollout/matrix vs handlers). Повертає структурований drift report з pass/fail.", "parameters": { "type": "object", "properties": { "action": { "type": "string", "enum": ["analyze"], "description": "Дія: analyze (запустити повний drift аналіз)" }, "categories": { "type": "array", "items": {"type": "string", "enum": ["services", "openapi", "nats", "tools"]}, "description": "Категорії для перевірки (default: всі)" }, "timeout_sec": { "type": "number", "description": "Таймаут в секундах (default: 25)", "default": 25 } }, "required": ["action"] } } }, # PRIORITY 9: Repo Tool (read-only filesystem/git access) # PRIORITY 9+: Alert Ingest Tool { "type": "function", "function": { "name": "alert_ingest_tool", "description": "🚨 Приймання, дедуплікація та управління алертами. Monitor надсилає структуровані алерти. Sofiia/oncall перетворює їх на інциденти. НЕ дає прав на incident_write.", "parameters": { "type": "object", "properties": { "action": { "type": "string", "enum": ["ingest", "list", "get", "ack", "claim", "fail"], "description": "Дія: ingest (Monitor), list (перегляд), get (деталі), ack (підтвердити), claim (атомарно взяти пачку для обробки), fail (відмітити як failed)" }, "alert": { "type": "object", "description": "AlertEvent (для ingest)", "properties": { "service": {"type": "string"}, "env": {"type": "string", "enum": ["prod", "staging", "dev"]}, "severity": {"type": "string", "enum": ["P0", "P1", "P2", "P3", "INFO"]}, "kind": {"type": "string", "enum": ["slo_breach", "crashloop", "latency", "error_rate", "disk", "oom", "deploy", "security", "custom"]}, "title": {"type": "string"}, "summary": {"type": "string"}, "started_at": {"type": "string"}, "source": {"type": "string"}, "labels": {"type": "object"}, "metrics": {"type": "object"}, "evidence": {"type": "object"} } }, "dedupe_ttl_minutes": {"type": "integer", "description": "TTL для дедуплікації (default: 30)"}, "alert_ref": {"type": "string", "description": "Референс алерту (для get/ack)"}, "actor": {"type": "string", "description": "Хто підтверджує (для ack)"}, "note": {"type": "string", "description": "Примітка (для ack)"}, "service": {"type": "string", "description": "Фільтр по сервісу (для list)"}, "env": {"type": "string", "description": "Фільтр по env (для list)"}, "window_minutes": {"type": "integer", "description": "Вікно аналізу (для list, default 240)"}, "limit": {"type": "integer", "description": "Ліміт результатів (default 50)"}, "status_in": {"type": "array", "items": {"type": "string"}, "description": "Фільтр по статусах (list/claim)"}, "owner": {"type": "string", "description": "Ідентифікатор власника lock (для claim)"}, "lock_ttl_seconds": {"type": "integer", "description": "TTL блокування в секундах (для claim, default 600)"}, "error": {"type": "string", "description": "Опис помилки (для fail)"}, "retry_after_seconds": {"type": "integer", "description": "Час до повтору в секундах (для fail, default 300)"} }, "required": ["action"] } } }, { "type": "function", "function": { "name": "incident_escalation_tool", "description": "⚡ Deterministic Incident Escalation Engine. Ескалює інциденти при storm-поведінці, визначає auto-resolve кандидатів. Без LLM, policy-driven.", "parameters": { "type": "object", "properties": { "action": { "type": "string", "enum": ["evaluate", "auto_resolve_candidates"], "description": "evaluate — ескалація по thresholds; auto_resolve_candidates — знайти інциденти без алертів" }, "env": {"type": "string", "description": "Фільтр по env (prod/staging/any)"}, "window_minutes": {"type": "integer", "description": "Вікно для аналізу (default 60)"}, "limit": {"type": "integer", "description": "Ліміт записів (default 100)"}, "no_alerts_minutes": {"type": "integer", "description": "Хвилин без алертів для auto_resolve_candidates"}, "dry_run": {"type": "boolean", "description": "dry_run=true — не записує зміни, лише повертає candidates"} }, "required": ["action"] } } }, { "type": "function", "function": { "name": "risk_engine_tool", "description": "📊 Service Risk Index Engine (deterministic, no LLM). Числовий ризик-скор сервісу: open incidents, recurrence, followups, SLO, escalations. Тепер включає trend (Δ24h, Δ7d, slope, volatility).", "parameters": { "type": "object", "properties": { "action": { "type": "string", "enum": ["service", "dashboard", "policy"], "description": "service — risk score для одного сервісу; dashboard — топ ризикових сервісів; policy — ефективна policy" }, "service": {"type": "string", "description": "Назва сервісу (для service action)"}, "env": {"type": "string", "description": "Середовище: prod|staging"}, "top_n": {"type": "integer", "description": "Топ N сервісів (для dashboard)"}, "window_hours": {"type": "integer", "description": "Вікно аналізу в годинах (default 24)"}, "include_trend": {"type": "boolean", "description": "Чи включати trend (Δ24h, Δ7d) з history store. Default true."} }, "required": ["action"] } } }, { "type": "function", "function": { "name": "risk_history_tool", "description": "📈 Risk History Tool — snapshot, cleanup, trend series для risk score. Зберігає hourly snapshots у Postgres. RBAC: tools.risk.write.", "parameters": { "type": "object", "properties": { "action": { "type": "string", "enum": ["snapshot", "cleanup", "series", "digest"], "description": "snapshot — зробити snapshot всіх сервісів; cleanup — видалити старі записи; series — отримати series для сервісу; digest — згенерувати daily digest" }, "env": {"type": "string", "description": "Середовище: prod|staging"}, "service": {"type": "string", "description": "Назва сервісу (для series action)"}, "hours": {"type": "integer", "description": "Вікно в годинах для series (default 168 = 7d)"}, "retention_days": {"type": "integer", "description": "Retention для cleanup (default з policy)"} }, "required": ["action"] } } }, { "type": "function", "function": { "name": "backlog_tool", "description": "📋 Engineering Backlog Tool — CRUD + workflow transitions + auto-generation from Risk/Pressure digests. Source-of-truth for platform engineering priorities. RBAC: tools.backlog.read/write/admin.", "parameters": { "type": "object", "properties": { "action": { "type": "string", "enum": ["list", "get", "dashboard", "create", "upsert", "set_status", "add_comment", "close", "auto_generate_weekly", "cleanup"], "description": "list/get/dashboard=read; create/upsert/set_status/add_comment/close=write; auto_generate_weekly/cleanup=admin" }, "env": {"type": "string", "description": "Environment: prod|staging|dev"}, "id": {"type": "string", "description": "Backlog item ID (for get/set_status/add_comment/close)"}, "service": {"type": "string", "description": "Filter by service name"}, "status": {"type": "string", "description": "Filter/set status"}, "owner": {"type": "string", "description": "Filter/set owner"}, "category": {"type": "string", "description": "Filter by category"}, "limit": {"type": "integer", "description": "Max results (list)"}, "offset": {"type": "integer", "description": "Pagination offset (list)"}, "due_before": {"type": "string", "description": "Filter due_date < YYYY-MM-DD"}, "item": {"type": "object", "description": "BacklogItem fields for create/upsert"}, "message": {"type": "string", "description": "Comment message or status note"}, "actor": {"type": "string", "description": "Who is making the change"}, "week_str": {"type": "string", "description": "ISO week override for auto_generate_weekly"}, "retention_days": {"type": "integer", "description": "Days to retain closed items (cleanup)"} }, "required": ["action"] } } }, { "type": "function", "function": { "name": "architecture_pressure_tool", "description": "🏗️ Architecture Pressure Tool — deterministic structural health index per service. Measures 30d accumulated strain (recurrences, regressions, escalations, followup debt). RBAC: tools.pressure.read/write.", "parameters": { "type": "object", "properties": { "action": { "type": "string", "enum": ["service", "dashboard", "digest"], "description": "service — pressure report for one service; dashboard — top-N pressure overview; digest — generate weekly platform priority digest" }, "service": {"type": "string", "description": "Service name (for service action)"}, "env": {"type": "string", "description": "Environment: prod|staging|dev"}, "top_n": {"type": "integer", "description": "Top-N services for dashboard/digest"}, "lookback_days": {"type": "integer", "description": "Lookback window in days (default 30)"}, "auto_followup": {"type": "boolean", "description": "Auto-create architecture-review followups (digest action)"} }, "required": ["action"] } } }, { "type": "function", "function": { "name": "incident_intelligence_tool", "description": "🧠 Incident Intelligence Layer (deterministic, no LLM). Кореляція, рекурентність, root-cause buckets, weekly digest — без LLM.", "parameters": { "type": "object", "properties": { "action": { "type": "string", "enum": ["correlate", "recurrence", "buckets", "weekly_digest"], "description": "correlate — related incidents; recurrence — frequency window; buckets — root-cause clusters; weekly_digest — full report" }, "incident_id": {"type": "string", "description": "ID інциденту для correlate"}, "window_days": {"type": "integer", "description": "Вікно аналізу в днях"}, "service": {"type": "string", "description": "Фільтр по сервісу (для recurrence/buckets)"}, "append_note": {"type": "boolean", "description": "Додати note в timeline інциденту (для correlate)"}, "save_artifacts": {"type": "boolean", "description": "Зберегти md+json у ops/reports/incidents (для weekly_digest)"} }, "required": ["action"] } } }, { "type": "function", "function": { "name": "repo_tool", "description": "📂 Read-only доступ до файловї системи репозиторію. Перегляд коду, конфігів, структури проєкту. НЕ записує і не виконує код.", "parameters": { "type": "object", "properties": { "action": { "type": "string", "enum": ["tree", "read", "search", "metadata"], "description": "Дія: tree (структура папок), read (читання файлу), search (пошук тексту), metadata (git info)" }, "path": { "type": "string", "description": "Шлях до файлу або папки відносно repo root" }, "start_line": { "type": "integer", "description": "Початковий рядок для читання (для read)" }, "end_line": { "type": "integer", "description": "Кінцевий рядок для читання (для read)" }, "depth": { "type": "integer", "description": "Глибина дерева для tree (default: 3)", "default": 3 }, "glob": { "type": "string", "description": "Glob паттерн для фільтрації (наприклад '**/*.py')" }, "query": { "type": "string", "description": "Текст для пошуку (для search)" }, "limit": { "type": "integer", "description": "Максимальна кількість результатів", "default": 50 }, "max_bytes": { "type": "integer", "description": "Максимальний розмір файлу для читання (default: 200KB)", "default": 204800 } }, "required": ["action"] } } }, # PRIORITY 10: PR Reviewer Tool { "type": "function", "function": { "name": "pr_reviewer_tool", "description": "🔍 Рев'ю коду з PR/diff. Аналізує зміни, знаходить security issues, blocking problems, ризики регресій. Підтримує blocking_only та full_review режими.", "parameters": { "type": "object", "properties": { "mode": { "type": "string", "enum": ["blocking_only", "full_review"], "description": "Режим рев'ю: blocking_only - тільки critical/high, full_review - повний аналіз", "default": "full_review" }, "context": { "type": "object", "properties": { "repo": { "type": "object", "properties": { "name": {"type": "string"}, "commit_base": {"type": "string"}, "commit_head": {"type": "string"} } }, "change_summary": {"type": "string"}, "risk_profile": { "type": "string", "enum": ["default", "security_strict", "release_gate"], "default": "default" } } }, "diff": { "type": "object", "properties": { "format": { "type": "string", "enum": ["unified"], "default": "unified" }, "text": { "type": "string", "description": "Unified diff текст" }, "max_files": { "type": "integer", "default": 200 }, "max_chars": { "type": "integer", "default": 400000 } }, "required": ["text"] }, "options": { "type": "object", "properties": { "include_tests_checklist": {"type": "boolean", "default": True}, "include_deploy_risks": {"type": "boolean", "default": True}, "include_migration_risks": {"type": "boolean", "default": True}, "language_hint": { "type": "string", "enum": ["python", "node", "mixed", "unknown"], "default": "unknown" } } } }, "required": ["diff"] } } }, # PRIORITY 11: Contract Tool (OpenAPI/JSON Schema) { "type": "function", "function": { "name": "contract_tool", "description": "📜 Перевірка OpenAPI контрактів. Lint, diff, breaking changes detection. Для QA та release gate.", "parameters": { "type": "object", "properties": { "action": { "type": "string", "enum": ["lint_openapi", "diff_openapi", "generate_client_stub"], "description": "Дія: lint - перевірка якості, diff - порівняння версій, stub - генерація клієнта" }, "inputs": { "type": "object", "properties": { "format": { "type": "string", "enum": ["openapi_json", "openapi_yaml"], "default": "openapi_yaml" }, "base": { "type": "object", "properties": { "source": {"type": "string", "enum": ["text", "repo_path"]}, "value": {"type": "string"} } }, "head": { "type": "object", "properties": { "source": {"type": "string", "enum": ["text", "repo_path"]}, "value": {"type": "string"} } } } }, "options": { "type": "object", "properties": { "fail_on_breaking": {"type": "boolean", "default": False}, "strict": {"type": "boolean", "default": False}, "max_chars": {"type": "integer", "default": 800000}, "service_name": {"type": "string"}, "visibility": {"type": "string", "enum": ["internal", "public"], "default": "internal"} } } }, "required": ["action", "inputs"] } } }, # PRIORITY 12: Oncall/Runbook Tool { "type": "function", "function": { "name": "oncall_tool", "description": "📋 Операційна інформація: сервіси, health, деплої, runbooks + повний incident CRUD (create/get/list/close/append_event/attach_artifact). Read-only для більшості, write для incident_* actions.", "parameters": { "type": "object", "properties": { "action": { "type": "string", "enum": [ "services_list", "service_status", "service_health", "deployments_recent", "runbook_search", "runbook_read", "incident_create", "incident_get", "incident_list", "incident_close", "incident_append_event", "incident_attach_artifact", "incident_followups_summary", "alert_to_incident", "signature_should_triage", "signature_mark_triage", "signature_mark_alert" ], "description": "Дія oncall tool" }, "params": { "type": "object", "properties": { "service_name": {"type": "string"}, "health_endpoint": {"type": "string"}, "since": {"type": "string"}, "severity": {"type": "string"}, "limit": {"type": "integer", "default": 20}, "query": {"type": "string"}, "runbook_path": {"type": "string"}, "workspace_id": {"type": "string"}, "incident_id": {"type": "string"}, "env": {"type": "string", "enum": ["prod", "staging", "dev"]}, "title": {"type": "string"}, "summary": {"type": "string"}, "started_at": {"type": "string"}, "ended_at": {"type": "string"}, "resolution_summary": {"type": "string"}, "status": {"type": "string"}, "service": {"type": "string"}, "type": {"type": "string", "enum": ["note", "decision", "action", "status_change", "followup", "metric", "log"]}, "message": {"type": "string"}, "meta": {"type": "object"}, "kind": {"type": "string", "enum": ["triage_report", "postmortem_draft", "postmortem_final", "attachment"]}, "format": {"type": "string", "enum": ["json", "md", "txt"]}, "content_base64": {"type": "string"}, "filename": {"type": "string"} } } }, "required": ["action"] } } }, # PRIORITY 13: Observability Tool (metrics/logs/traces) { "type": "function", "function": { "name": "observability_tool", "description": "📊 Метрики, логи, трейси. Prometheus, Loki, Tempo інтеграція. Read-only доступ до observability даних.", "parameters": { "type": "object", "properties": { "action": { "type": "string", "enum": [ "metrics_query", "metrics_range", "logs_query", "traces_query", "service_overview", "slo_snapshot" ], "description": "Дія: метрики, логи, трейси, SLO" }, "params": { "type": "object", "properties": { "service": {"type": "string"}, "query": {"type": "string"}, "time_range": { "type": "object", "properties": { "from": {"type": "string"}, "to": {"type": "string"} } }, "step_seconds": {"type": "integer", "default": 30}, "limit": {"type": "integer", "default": 200}, "datasource": { "type": "string", "enum": ["prometheus", "loki", "tempo"], "default": "prometheus" }, "filters": {"type": "object"}, "trace_id": {"type": "string"} } } }, "required": ["action"] } } }, { "type": "function", "function": { "name": "config_linter_tool", "description": "🔒 Перевірка конфігурацій на secrets, небезпечні налаштування, policy violations. Детермінований linter без LLM.", "parameters": { "type": "object", "properties": { "source": { "type": "object", "properties": { "type": { "type": "string", "enum": ["diff_text", "paths"], "description": "Джерело для перевірки: diff (unified format) або шляхи до файлів" }, "diff_text": {"type": "string", "description": "Unified diff текст (якщо type=diff_text)"}, "paths": { "type": "array", "items": {"type": "string"}, "description": "Список шляхів до файлів (якщо type=paths)" } }, "required": ["type"] }, "options": { "type": "object", "properties": { "strict": {"type": "boolean", "default": False, "description": "Strict mode - fail на medium"}, "max_chars": {"type": "integer", "default": 400000}, "max_files": {"type": "integer", "default": 200}, "mask_evidence": {"type": "boolean", "default": True}, "include_recommendations": {"type": "boolean", "default": True} } } }, "required": ["source"] } } }, { "type": "function", "function": { "name": "threatmodel_tool", "description": "🛡️ Threat Model + Security Checklist. STRIDE-based аналіз сервісів, diff, OpenAPI. Генерує assets, entrypoints, threats, controls, security checklist.", "parameters": { "type": "object", "properties": { "action": { "type": "string", "enum": ["analyze_service", "analyze_diff", "generate_checklist"], "description": "Дія: аналіз сервісу, дифу, або генерація чеклісту" }, "inputs": { "type": "object", "properties": { "service_name": {"type": "string", "description": "Назва сервісу"}, "artifacts": { "type": "array", "items": { "type": "object", "properties": { "type": {"type": "string", "enum": ["openapi", "diff", "text", "dataflows"]}, "source": {"type": "string", "enum": ["text", "repo_path"]}, "value": {"type": "string"} }, "required": ["type", "source", "value"] } } } }, "options": { "type": "object", "properties": { "max_chars": {"type": "integer", "default": 600000}, "strict": {"type": "boolean", "default": False}, "include_llm_enrichment": {"type": "boolean", "default": False}, "risk_profile": { "type": "string", "enum": ["default", "agentic_tools", "public_api"], "default": "default" } } } }, "required": ["action", "inputs"] } } }, { "type": "function", "function": { "name": "job_orchestrator_tool", "description": "🚀 Job Orchestrator - Запуск контрольованих операційних задач (deploy/check/backup/smoke/drift). Dry-run, RBAC, аудит.", "parameters": { "type": "object", "properties": { "action": { "type": "string", "enum": ["list_tasks", "start_task", "get_job", "cancel_job"], "description": "Дія: список задач, запуск, статус, скасування" }, "params": { "type": "object", "properties": { "filter": { "type": "object", "properties": { "tag": {"type": "string", "enum": ["deploy", "backup", "smoke", "migrate", "drift", "ops"]}, "service": {"type": "string"} } }, "task_id": {"type": "string", "description": "ID задачі для запуску"}, "dry_run": {"type": "boolean", "default": False, "description": "Тільки план виконання"}, "inputs": {"type": "object", "description": "Параметри задачі"}, "idempotency_key": {"type": "string", "description": "Унікальний ключ для запобігання дублів"}, "job_id": {"type": "string", "description": "ID job для отримання статусу"}, "reason": {"type": "string", "description": "Причина скасування"} } } }, "required": ["action"] } } }, { "type": "function", "function": { "name": "kb_tool", "description": "📚 Knowledge Base - Пошук ADR, документації, runbooks. Швидкий доступ до організаційної правди з цитуванням.", "parameters": { "type": "object", "properties": { "action": { "type": "string", "enum": ["search", "open", "snippets", "sources"], "description": "Дія: пошук, відкрити файл, сниппети, список джерел" }, "params": { "type": "object", "properties": { "query": {"type": "string", "description": "Пошуковий запит"}, "paths": { "type": "array", "items": {"type": "string"}, "description": "Директорії для пошуку" }, "file_glob": {"type": "string", "description": "Glob паттерн файлів"}, "limit": {"type": "integer", "default": 20, "description": "Ліміт результатів"}, "start_line": {"type": "integer", "description": "Початкова лінія"}, "end_line": {"type": "integer", "description": "Кінцева лінія"}, "max_bytes": {"type": "integer", "default": 200000}, "context_lines": {"type": "integer", "default": 4, "description": "Лінії контексту"}, "max_chars_per_snippet": {"type": "integer", "default": 800}, "path": {"type": "string", "description": "Шлях до файлу"} } } }, "required": ["action"] } } }, { "type": "function", "function": { "name": "notion_tool", "description": "🗂️ Notion integration: create/update databases, pages, tasks; check API connectivity.", "parameters": { "type": "object", "properties": { "action": { "type": "string", "enum": ["status", "create_database", "create_page", "update_page", "create_task"], "description": "Notion action" }, "database_id": {"type": "string", "description": "Target database ID"}, "page_id": {"type": "string", "description": "Target page ID"}, "parent_page_id": {"type": "string", "description": "Parent page ID for database"}, "title": {"type": "string", "description": "Title for page/task/database"}, "properties": {"type": "object", "description": "Raw Notion properties object"}, "content": {"type": "string", "description": "Optional page body text"}, "status": {"type": "string", "description": "Task status"}, "due_date": {"type": "string", "description": "Task due date (YYYY-MM-DD)"} }, "required": ["action"] } } }, { "type": "function", "function": { "name": "pieces_tool", "description": "🧩 Pieces OS integration: status/ping for local workstream processors.", "parameters": { "type": "object", "properties": { "action": { "type": "string", "enum": ["status", "workstream_status", "ping"], "description": "Pieces action (defaults to status)" }, "base_url": { "type": "string", "description": "Optional override for Pieces OS base URL" } } } } }, { "type": "function", "function": { "name": "calendar_tool", "description": "📅 Calendar management via calendar-service (CalDAV/Radicale).", "parameters": { "type": "object", "properties": { "action": { "type": "string", "enum": ["connect", "list_calendars", "list_events", "get_event", "create_event", "update_event", "delete_event", "set_reminder"], "description": "Calendar action" }, "workspace_id": {"type": "string", "description": "Workspace id"}, "user_id": {"type": "string", "description": "User id"}, "account_id": {"type": "string", "description": "Connected calendar account id"}, "calendar_id": {"type": "string", "description": "Optional calendar id"}, "params": {"type": "object", "description": "Action-specific payload"} }, "required": ["action"] } } }, { "type": "function", "function": { "name": "agent_email_tool", "description": "✉️ Agent email automation: inbox lifecycle, send/receive, email analysis.", "parameters": { "type": "object", "properties": { "action": { "type": "string", "enum": ["create_inbox", "list_inboxes", "delete_inbox", "send", "receive", "analyze_email"], "description": "Email action" }, "to": {"type": "array", "items": {"type": "string"}}, "subject": {"type": "string"}, "body": {"type": "string"}, "html": {"type": "string"}, "attachments": {"type": "array", "items": {"type": "string"}}, "cc": {"type": "array", "items": {"type": "string"}}, "bcc": {"type": "array", "items": {"type": "string"}}, "inbox_id": {"type": "string"}, "unread_only": {"type": "boolean"}, "limit": {"type": "integer"}, "query": {"type": "string"}, "email": {"type": "object"} }, "required": ["action"] } } }, { "type": "function", "function": { "name": "browser_tool", "description": "🌐 Browser automation with managed per-agent sessions.", "parameters": { "type": "object", "properties": { "action": { "type": "string", "enum": ["start_session", "restore_session", "close_session", "goto", "act", "extract", "observe", "screenshot", "fill_form", "wait_for", "get_current_url", "get_page_text"], "description": "Browser action" }, "url": {"type": "string"}, "instruction": {"type": "string"}, "schema": {"type": "object"}, "fields": {"type": "array", "items": {"type": "object"}}, "selector_or_text": {"type": "string"}, "timeout": {"type": "integer"}, "headless": {"type": "boolean"}, "proxy": {"type": "string"}, "stealth": {"type": "boolean"}, "restore_existing": {"type": "boolean"} }, "required": ["action"] } } }, { "type": "function", "function": { "name": "safe_code_executor_tool", "description": "🧪 Sandboxed code execution with security validation and limits.", "parameters": { "type": "object", "properties": { "action": { "type": "string", "enum": ["validate", "execute", "execute_async", "get_job_result", "get_stats", "kill_process"], "description": "Executor action" }, "language": {"type": "string", "enum": ["python", "javascript", "js"]}, "code": {"type": "string"}, "stdin": {"type": "string"}, "limits": {"type": "object"}, "job_id": {"type": "string"}, "execution_id": {"type": "string"} }, "required": ["action"] } } }, { "type": "function", "function": { "name": "secure_vault_tool", "description": "🔐 Encrypted credential storage for agents.", "parameters": { "type": "object", "properties": { "action": { "type": "string", "enum": ["store", "get", "delete", "list", "check_expiring", "vacuum"], "description": "Vault action" }, "service": {"type": "string"}, "credential_name": {"type": "string"}, "value": {}, "ttl_seconds": {"type": "integer"}, "days": {"type": "integer"} }, "required": ["action"] } } } ] @dataclass class ToolResult: """Result of tool execution""" success: bool result: Any error: Optional[str] = None image_base64: Optional[str] = None # For image generation results file_base64: Optional[str] = None file_name: Optional[str] = None file_mime: Optional[str] = None class ToolManager: """Manages tool execution for the agent""" def __init__(self, config: Dict[str, Any]): self.config = config self.http_client = httpx.AsyncClient(timeout=60.0) self.swapper_url = os.getenv("SWAPPER_URL", "http://swapper-service:8890") self.comfy_agent_url = os.getenv("COMFY_AGENT_URL", "http://212.8.58.133:8880") self.image_gen_service_url = os.getenv("IMAGE_GEN_SERVICE_URL", "http://image-gen-service:7860") self.calendar_service_url = os.getenv("CALENDAR_SERVICE_URL", "http://calendar-service:8001").rstrip("/") self.oneok_crm_url = os.getenv("ONEOK_CRM_BASE_URL", "http://oneok-crm-adapter:8088").rstrip("/") self.oneok_calc_url = os.getenv("ONEOK_CALC_BASE_URL", "http://oneok-calc-adapter:8089").rstrip("/") self.oneok_docs_url = os.getenv("ONEOK_DOCS_BASE_URL", "http://oneok-docs-adapter:8090").rstrip("/") self.oneok_schedule_url = os.getenv("ONEOK_SCHEDULE_BASE_URL", "http://oneok-schedule-adapter:8091").rstrip("/") self.oneok_adapter_api_key = os.getenv("ONEOK_ADAPTER_API_KEY", "").strip() self._agent_email_clients: Dict[str, Any] = {} self._browser_clients: Dict[str, Any] = {} self._safe_code_executor: Any = None self._secure_vault: Any = None self.tools_config = self._load_tools_config() def _load_tools_config(self) -> Dict[str, Dict]: """Load tool endpoints from config""" tools = {} agent_config = self.config.get("agents", {}).get("helion", {}) for tool in agent_config.get("tools", []): if "endpoint" in tool: tools[tool["id"]] = { "endpoint": tool["endpoint"], "method": tool.get("method", "POST") } return tools def get_tool_definitions(self, agent_id: str = None) -> List[Dict]: """Get tool definitions for function calling, filtered by agent permissions""" if not agent_id: return TOOL_DEFINITIONS # Get allowed tools for this agent allowed_tools = get_agent_tools(agent_id) # Filter tool definitions filtered = [] for tool_def in TOOL_DEFINITIONS: tool_name = tool_def.get("function", {}).get("name") if tool_name in allowed_tools: filtered.append(tool_def) tool_names = [t.get("function", {}).get("name") for t in filtered] logger.debug(f"Agent {agent_id} has {len(filtered)} tools: {tool_names}") return filtered async def execute_tool( self, tool_name: str, arguments: Dict[str, Any], agent_id: str = None, chat_id: str = None, user_id: str = None, workspace_id: str = "default", ) -> ToolResult: """Execute a tool and return result. Applies governance middleware (RBAC, limits, audit).""" logger.info(f"🔧 Executing tool: {tool_name} for agent={agent_id}") # Check agent permission if agent_id provided if agent_id and not is_tool_allowed(agent_id, tool_name): logger.warning(f"⚠️ Tool {tool_name} not allowed for agent {agent_id}") return ToolResult(success=False, result=None, error=f"Tool {tool_name} not available for this agent") # ── Tool Governance: pre-call (RBAC + limits) ────────────────────── try: from tool_governance import get_governance governance = get_governance() # Determine action from arguments (best-effort) _action = (arguments or {}).get("action", "_default") _input_text = str(arguments) if arguments else "" pre = governance.pre_call( tool=tool_name, action=_action, agent_id=agent_id or "anonymous", user_id=user_id or "unknown", workspace_id=workspace_id, input_text=_input_text, ) if not pre.allowed: return ToolResult(success=False, result=None, error=pre.reason) _gov_ctx = pre.call_ctx except ImportError: logger.debug("tool_governance not available, skipping governance checks") governance = None _gov_ctx = None result: ToolResult = ToolResult(success=False, result=None, error="Unknown tool") try: # Priority 1: Memory/Knowledge tools if tool_name == "memory_search": result = await self._memory_search(arguments, agent_id=agent_id, chat_id=chat_id, user_id=user_id) elif tool_name == "graph_query": result = await self._graph_query(arguments, agent_id=agent_id) # Priority 2: Web tools elif tool_name == "web_search": result = await self._web_search(arguments) elif tool_name == "web_extract": result = await self._web_extract(arguments) elif tool_name == "image_generate": result = await self._image_generate(arguments) elif tool_name == "comfy_generate_image": result = await self._comfy_generate_image(arguments) elif tool_name == "comfy_generate_video": result = await self._comfy_generate_video(arguments) elif tool_name == "remember_fact": result = await self._remember_fact(arguments, agent_id=agent_id, chat_id=chat_id, user_id=user_id) # Priority 4: Presentation tools elif tool_name == "presentation_create": result = await self._presentation_create(arguments) elif tool_name == "presentation_status": result = await self._presentation_status(arguments) elif tool_name == "presentation_download": result = await self._presentation_download(arguments) # Priority 5: Web scraping tools elif tool_name == "crawl4ai_scrape": result = await self._crawl4ai_scrape(arguments) # Priority 6: TTS tools elif tool_name == "tts_speak": result = await self._tts_speak(arguments) # Priority 6: File artifacts elif tool_name == "file_tool": result = await self._file_tool(arguments) # Priority 7: Market Data (SenpAI) elif tool_name == "market_data": result = await self._market_data(arguments) elif tool_name == "binance_bots_top": result = await self._binance_bots_top(arguments) elif tool_name == "binance_account_bots": result = await self._binance_account_bots(arguments) # Priority 8: 1OK tools elif tool_name == "crm_search_client": result = await self._crm_search_client(arguments) elif tool_name == "crm_upsert_client": result = await self._crm_upsert_client(arguments) elif tool_name == "crm_upsert_site": result = await self._crm_upsert_site(arguments) elif tool_name == "crm_upsert_window_unit": result = await self._crm_upsert_window_unit(arguments) elif tool_name == "crm_create_quote": result = await self._crm_create_quote(arguments) elif tool_name == "crm_update_quote": result = await self._crm_update_quote(arguments) elif tool_name == "crm_create_job": result = await self._crm_create_job(arguments) elif tool_name == "calc_window_quote": result = await self._calc_window_quote(arguments) elif tool_name == "docs_render_quote_pdf": result = await self._docs_render_quote_pdf(arguments) elif tool_name == "docs_render_invoice_pdf": result = await self._docs_render_invoice_pdf(arguments) elif tool_name == "schedule_propose_slots": result = await self._schedule_propose_slots(arguments) elif tool_name == "schedule_confirm_slot": result = await self._schedule_confirm_slot(arguments) # Priority 9+: Data Governance Tool elif tool_name == "data_governance_tool": result = await self._data_governance_tool(arguments) # Priority 9+: Cost Analyzer Tool elif tool_name == "cost_analyzer_tool": result = await self._cost_analyzer_tool(arguments) # Priority 9+: Dependency Scanner Tool elif tool_name == "dependency_scanner_tool": result = await self._dependency_scanner_tool(arguments) # Priority 9+: Drift Analyzer Tool elif tool_name == "drift_analyzer_tool": result = await self._drift_analyzer_tool(arguments) # Alert Ingest Tool elif tool_name == "alert_ingest_tool": result = await self._alert_ingest_tool(arguments) elif tool_name == "incident_escalation_tool": result = await self._incident_escalation_tool(arguments) elif tool_name == "incident_intelligence_tool": result = await self._incident_intelligence_tool(arguments) elif tool_name == "risk_engine_tool": result = await self._risk_engine_tool(arguments, agent_id=agent_id) elif tool_name == "risk_history_tool": result = await self._risk_history_tool(arguments, agent_id=agent_id) elif tool_name == "backlog_tool": result = await self._backlog_tool(arguments, agent_id=agent_id) elif tool_name == "architecture_pressure_tool": result = await self._architecture_pressure_tool(arguments, agent_id=agent_id) # Priority 9: Repo Tool elif tool_name == "repo_tool": result = await self._repo_tool(arguments) # Priority 10: PR Reviewer Tool elif tool_name == "pr_reviewer_tool": result = await self._pr_reviewer_tool(arguments) # Priority 11: Contract Tool (OpenAPI) elif tool_name == "contract_tool": result = await self._contract_tool(arguments) # Priority 12: Oncall/Runbook Tool elif tool_name == "oncall_tool": result = await self._oncall_tool(arguments, agent_id=agent_id) # Priority 13: Observability Tool elif tool_name == "observability_tool": result = await self._observability_tool(arguments) # Priority 14: Config Linter Tool elif tool_name == "config_linter_tool": result = await self._config_linter_tool(arguments) # Priority 15: ThreatModel Tool elif tool_name == "threatmodel_tool": result = await self._threatmodel_tool(arguments) # Priority 16: Job Orchestrator Tool elif tool_name == "job_orchestrator_tool": result = await self._job_orchestrator_tool(arguments) # Priority 17: Knowledge Base Tool elif tool_name == "kb_tool": result = await self._kb_tool(arguments) # Priority 18: Notion integration elif tool_name == "notion_tool": result = await self._notion_tool(arguments) # Priority 18: Pieces OS integration elif tool_name == "pieces_tool": result = await self._pieces_tool(arguments) # Priority 19: Calendar integration elif tool_name == "calendar_tool": enriched = dict(arguments or {}) enriched.setdefault("agent_id", agent_id or "sofiia") enriched.setdefault("workspace_id", workspace_id or "default") if user_id: enriched.setdefault("user_id", user_id) result = await self._calendar_tool(enriched) # Priority 20: Local automations toolkit elif tool_name == "agent_email_tool": enriched = dict(arguments or {}) enriched.setdefault("agent_id", agent_id or "sofiia") result = await self._agent_email_tool(enriched) elif tool_name == "browser_tool": enriched = dict(arguments or {}) enriched.setdefault("agent_id", agent_id or "sofiia") result = await self._browser_tool(enriched) elif tool_name == "safe_code_executor_tool": enriched = dict(arguments or {}) enriched.setdefault("agent_id", agent_id or "sofiia") result = await self._safe_code_executor_tool(enriched) elif tool_name == "secure_vault_tool": enriched = dict(arguments or {}) enriched.setdefault("agent_id", agent_id or "sofiia") result = await self._secure_vault_tool(enriched) else: result = ToolResult(success=False, result=None, error=f"Unknown tool: {tool_name}") except Exception as e: logger.error(f"Tool execution failed: {e}") result = ToolResult(success=False, result=None, error=str(e)) # ── Tool Governance: post-call audit ────────────────────────────── if governance and _gov_ctx: try: governance.post_call( _gov_ctx, result_value=result.result if result else None, error=result.error if result and not result.success else None, ) except Exception as _ae: logger.debug(f"Audit emit failed (non-fatal): {_ae}") return result @staticmethod def _sanitize_file_name(name: Optional[str], default_name: str, force_ext: Optional[str] = None) -> str: raw = (name or default_name).strip() or default_name base = PurePath(raw).name if not base: base = default_name if force_ext and not base.lower().endswith(force_ext): base = f"{base}{force_ext}" return base @staticmethod def _b64_from_bytes(data: bytes) -> str: return base64.b64encode(data).decode("utf-8") @staticmethod def _bytes_from_b64(value: str) -> bytes: return base64.b64decode(value) @staticmethod def _normalize_rows(rows: Any, headers: Optional[List[str]] = None) -> List[List[Any]]: if not isinstance(rows, list): return [] out: List[List[Any]] = [] for row in rows: if isinstance(row, list): out.append(row) elif isinstance(row, dict): keys = headers or list(row.keys()) out.append([row.get(k, "") for k in keys]) else: out.append([row]) return out @staticmethod def _append_sheet_data(ws: Any, headers: List[str], rows: List[List[Any]]) -> None: if headers: ws.append(headers) for row in rows: ws.append(row) async def _file_tool(self, args: Dict[str, Any]) -> ToolResult: action = str((args or {}).get("action") or "").strip().lower() if not action: return ToolResult(success=False, result=None, error="Missing action") if action == "excel_create": return self._file_excel_create(args) if action == "excel_update": return self._file_excel_update(args) if action == "csv_create": return self._file_csv_create(args) if action == "csv_update": return self._file_csv_update(args) if action == "ods_create": return self._file_ods_create(args) if action == "ods_update": return self._file_ods_update(args) if action == "parquet_create": return self._file_parquet_create(args) if action == "parquet_update": return self._file_parquet_update(args) if action == "text_create": return self._file_text_create(args) if action == "text_update": return self._file_text_update(args) if action == "markdown_create": return self._file_markdown_create(args) if action == "markdown_update": return self._file_markdown_update(args) if action == "xml_export": return self._file_xml_export(args) if action == "html_export": return self._file_html_export(args) if action == "image_create": return self._file_image_create(args) if action == "image_edit": return self._file_image_edit(args) if action == "image_convert": return self._file_image_convert(args) if action == "image_bundle": return self._file_image_bundle(args) if action == "svg_export": return self._file_svg_export(args) if action == "svg_to_png": return self._file_svg_to_png(args) if action == "json_export": return self._file_json_export(args) if action == "yaml_export": return self._file_yaml_export(args) if action == "zip_bundle": return self._file_zip_bundle(args) if action == "docx_create": return self._file_docx_create(args) if action == "docx_update": return self._file_docx_update(args) if action == "pptx_create": return self._file_pptx_create(args) if action == "pptx_update": return self._file_pptx_update(args) if action == "pdf_merge": return self._file_pdf_merge(args) if action == "pdf_split": return self._file_pdf_split(args) if action == "pdf_update": return self._file_pdf_update(args) if action == "pdf_fill": return self._file_pdf_fill(args) if action == "djvu_to_pdf": return self._file_djvu_to_pdf(args) if action == "djvu_extract_text": return self._file_djvu_extract_text(args) return ToolResult(success=False, result=None, error=f"Action not implemented yet: {action}") async def _job_orchestrator_tool(self, args: Dict[str, Any]) -> ToolResult: """ Minimal stable implementation for ops orchestration. Supports list_tasks and start_task(release_check). """ payload = args or {} action = str(payload.get("action") or "list_tasks").strip().lower() params = payload.get("params") if isinstance(payload.get("params"), dict) else {} if action == "list_tasks": return ToolResult( success=True, result={ "tasks": [ {"task_id": "release_check", "description": "Run release governance gates"}, ] }, ) if action == "start_task": task_id = payload.get("task_id") or params.get("task_id") inputs = payload.get("inputs") or params.get("inputs") or {} agent_id = str(payload.get("agent_id") or "sofiia") if task_id != "release_check": return ToolResult( success=False, result=None, error=f"Unsupported task_id: {task_id}", ) try: from release_check_runner import run_release_check result = await run_release_check(self, inputs if isinstance(inputs, dict) else {}, agent_id) return ToolResult(success=True, result=result) except Exception as e: logger.exception("job_orchestrator_tool start_task failed") return ToolResult(success=False, result=None, error=f"release_check failed: {str(e)}") return ToolResult(success=False, result=None, error=f"Unsupported action: {action}") async def _threatmodel_tool(self, args: Dict[str, Any]) -> ToolResult: """ Lightweight threat-model gate implementation for release checks. """ payload = args or {} action = str(payload.get("action") or "analyze_diff").strip().lower() diff_text = str(payload.get("diff_text") or "") service_name = str(payload.get("service_name") or "unknown") risk_profile = str(payload.get("risk_profile") or "default") if action not in {"analyze_diff", "analyze_service"}: return ToolResult(success=False, result=None, error=f"Unsupported action: {action}") unmitigated_high = 0 findings = [] # Conservative heuristic: detect obvious high-risk markers in changed text. high_markers = ("skip auth", "auth bypass", "debug=true", "tls: false", "privileged: true") low = diff_text.lower() for marker in high_markers: if marker in low: unmitigated_high += 1 findings.append({"severity": "high", "marker": marker}) summary = ( f"Threat model for {service_name}: {unmitigated_high} high-risk marker(s)" if diff_text else f"Threat model for {service_name}: no diff provided (baseline pass)" ) return ToolResult( success=True, result={ "summary": summary, "risk_profile": risk_profile, "unmitigated_high_count": unmitigated_high, "findings": findings, "recommendations": ( ["Review auth/TLS/debug settings in the changed files"] if unmitigated_high > 0 else [] ), }, ) async def _config_linter_tool(self, args: Dict[str, Any]) -> ToolResult: """ Lightweight config lint gate for release checks. """ payload = args or {} diff_text = str(payload.get("diff_text") or "") # If no diff provided, gate should not block release. if not diff_text.strip(): return ToolResult( success=True, result={ "summary": "No diff provided; config lint skipped", "blocking_count": 0, "total_findings": 0, "blocking": [], "findings": [], }, ) blocking = [] low = diff_text.lower() if "debug=true" in low or "auth_bypass" in low or "tls: false" in low: blocking.append({"severity": "high", "rule": "unsafe_config_change"}) return ToolResult( success=True, result={ "summary": f"Config lint found {len(blocking)} blocking issue(s)", "blocking_count": len(blocking), "total_findings": len(blocking), "blocking": blocking, "findings": [], }, ) async def _observability_tool(self, args: Dict[str, Any]) -> ToolResult: """ Lightweight observability tool for slo_snapshot gate. """ payload = args or {} action = str(payload.get("action") or "").strip().lower() if action != "slo_snapshot": return ToolResult(success=False, result=None, error=f"Unsupported action: {action}") return ToolResult( success=True, result={ "violations": [], "metrics": {}, "thresholds": {}, "skipped": True, }, ) async def _notion_tool(self, args: Dict[str, Any]) -> ToolResult: """ Notion API integration: - status - create_database - create_page - update_page - create_task """ payload = args or {} action = str(payload.get("action") or "").strip().lower() api_key = os.getenv("NOTION_API_KEY", os.getenv("NOTION_TOKEN", "")).strip() notion_ver = os.getenv("NOTION_VERSION", "2022-06-28").strip() if not api_key: return ToolResult(success=False, result=None, error="Notion API key not configured (NOTION_API_KEY)") if not action: return ToolResult(success=False, result=None, error="Missing action") headers = { "Authorization": f"Bearer {api_key}", "Notion-Version": notion_ver, "Content-Type": "application/json", } base_url = "https://api.notion.com/v1" try: if action == "status": resp = await self.http_client.get(f"{base_url}/users/me", headers=headers, timeout=8.0) ok = resp.status_code == 200 data = resp.json() if ok else {"status_code": resp.status_code, "text": resp.text[:300]} return ToolResult(success=ok, result={"status": "ok" if ok else "failed", "data": data}, error=None if ok else f"Notion HTTP {resp.status_code}") if action == "create_database": parent_page_id = str(payload.get("parent_page_id") or "").strip() title = str(payload.get("title") or "New Database").strip() if not parent_page_id: return ToolResult(success=False, result=None, error="parent_page_id is required") body = { "parent": {"type": "page_id", "page_id": parent_page_id}, "title": [{"type": "text", "text": {"content": title}}], "properties": payload.get("properties") or { "Name": {"title": {}}, "Status": {"select": {"options": [{"name": "Todo"}, {"name": "In Progress"}, {"name": "Done"}]}}, }, } resp = await self.http_client.post(f"{base_url}/databases", headers=headers, json=body, timeout=20.0) if resp.status_code not in (200, 201): return ToolResult(success=False, result=None, error=f"Notion HTTP {resp.status_code}: {resp.text[:300]}") return ToolResult(success=True, result=resp.json()) if action == "create_page": database_id = str(payload.get("database_id") or "").strip() title = str(payload.get("title") or "").strip() if not database_id: return ToolResult(success=False, result=None, error="database_id is required") if not title: return ToolResult(success=False, result=None, error="title is required") props = payload.get("properties") or {} props.setdefault("Name", {"title": [{"type": "text", "text": {"content": title}}]}) body: Dict[str, Any] = {"parent": {"database_id": database_id}, "properties": props} content = str(payload.get("content") or "").strip() if content: body["children"] = [{ "object": "block", "type": "paragraph", "paragraph": {"rich_text": [{"type": "text", "text": {"content": content}}]}, }] resp = await self.http_client.post(f"{base_url}/pages", headers=headers, json=body, timeout=20.0) if resp.status_code not in (200, 201): return ToolResult(success=False, result=None, error=f"Notion HTTP {resp.status_code}: {resp.text[:300]}") return ToolResult(success=True, result=resp.json()) if action == "update_page": page_id = str(payload.get("page_id") or "").strip() if not page_id: return ToolResult(success=False, result=None, error="page_id is required") body: Dict[str, Any] = {} if isinstance(payload.get("properties"), dict): body["properties"] = payload.get("properties") if "archived" in payload: body["archived"] = bool(payload.get("archived")) if not body: return ToolResult(success=False, result=None, error="nothing to update (properties|archived)") resp = await self.http_client.patch(f"{base_url}/pages/{page_id}", headers=headers, json=body, timeout=20.0) if resp.status_code != 200: return ToolResult(success=False, result=None, error=f"Notion HTTP {resp.status_code}: {resp.text[:300]}") return ToolResult(success=True, result=resp.json()) if action == "create_task": database_id = str(payload.get("database_id") or "").strip() title = str(payload.get("title") or "").strip() if not database_id: return ToolResult(success=False, result=None, error="database_id is required") if not title: return ToolResult(success=False, result=None, error="title is required") status = str(payload.get("status") or "Todo").strip() due_date = str(payload.get("due_date") or "").strip() props: Dict[str, Any] = { "Name": {"title": [{"type": "text", "text": {"content": title}}]}, "Status": {"select": {"name": status}}, } if due_date: props["Due"] = {"date": {"start": due_date}} user_props = payload.get("properties") if isinstance(user_props, dict): props.update(user_props) body = {"parent": {"database_id": database_id}, "properties": props} resp = await self.http_client.post(f"{base_url}/pages", headers=headers, json=body, timeout=20.0) if resp.status_code not in (200, 201): return ToolResult(success=False, result=None, error=f"Notion HTTP {resp.status_code}: {resp.text[:300]}") return ToolResult(success=True, result=resp.json()) return ToolResult(success=False, result=None, error=f"Unsupported action: {action}") except Exception as e: return ToolResult(success=False, result=None, error=f"Notion error: {str(e)}") async def _pieces_tool(self, args: Dict[str, Any]) -> ToolResult: """ Pieces OS integration (local runtime on host machine). Default action returns processor status from Pieces OS. """ payload = args or {} action = str(payload.get("action") or "status").strip().lower() base_url = str( payload.get("base_url") or os.getenv("PIECES_OS_URL", "http://host.docker.internal:39300") ).rstrip("/") if action in {"status", "workstream_status"}: path = "/workstream_pattern_engine/processors/status" elif action == "ping": path = "/workstream_pattern_engine/processors/status" else: return ToolResult( success=False, result=None, error=f"Unsupported action: {action}. Use status|ping", ) url = f"{base_url}{path}" try: resp = await self.http_client.get(url, timeout=5.0) if resp.status_code != 200: return ToolResult( success=False, result=None, error=f"Pieces OS HTTP {resp.status_code} at {url}", ) data = resp.json() return ToolResult( success=True, result={ "provider": "pieces_os", "url": url, "status": "ok", "data": data, }, ) except Exception as e: return ToolResult( success=False, result=None, error=f"Pieces OS unavailable: {str(e)}", ) async def _calendar_tool(self, args: Dict[str, Any]) -> ToolResult: payload = args or {} action = str(payload.get("action") or "").strip() if not action: return ToolResult(success=False, result=None, error="Missing action") req = { "action": action, "workspace_id": str(payload.get("workspace_id") or "default"), "user_id": str(payload.get("user_id") or payload.get("agent_id") or "sofiia"), "account_id": payload.get("account_id"), "calendar_id": payload.get("calendar_id"), "params": payload.get("params") if isinstance(payload.get("params"), dict) else {}, } headers = { "x-agent-id": str(payload.get("agent_id") or "sofiia"), "x-workspace-id": req["workspace_id"], "content-type": "application/json", } url = f"{self.calendar_service_url}/v1/tools/calendar" try: resp = await self.http_client.post(url, json=req, headers=headers, timeout=20.0) if resp.status_code != 200: return ToolResult(success=False, result=None, error=f"calendar-service HTTP {resp.status_code}: {resp.text[:300]}") data = resp.json() if isinstance(data, dict) and data.get("status") in {"failed", "error"}: return ToolResult(success=False, result=None, error=str(data.get("detail") or data.get("error") or "Calendar error")) return ToolResult(success=True, result=data) except Exception as e: return ToolResult(success=False, result=None, error=f"Calendar service unavailable: {e}") def _get_agent_email_client(self, agent_id: str): if agent_id in self._agent_email_clients: return self._agent_email_clients[agent_id] try: from tools.agent_email.agent_email import AgentEmailTool # type: ignore except Exception as e: raise RuntimeError(f"AgentEmailTool import failed: {e}") client = AgentEmailTool(agent_id=agent_id) self._agent_email_clients[agent_id] = client return client def _get_browser_client(self, agent_id: str): if agent_id in self._browser_clients: return self._browser_clients[agent_id] try: from tools.browser_tool.browser_tool import BrowserTool # type: ignore except Exception as e: raise RuntimeError(f"BrowserTool import failed: {e}") client = BrowserTool(agent_id=agent_id, headless=True) self._browser_clients[agent_id] = client return client def _get_safe_executor(self): if self._safe_code_executor is not None: return self._safe_code_executor try: from tools.safe_code_executor.safe_code_executor import SafeCodeExecutor # type: ignore except Exception as e: raise RuntimeError(f"SafeCodeExecutor import failed: {e}") self._safe_code_executor = SafeCodeExecutor() return self._safe_code_executor def _get_secure_vault(self): if self._secure_vault is not None: return self._secure_vault try: from tools.secure_vault.secure_vault import SecureVault # type: ignore except Exception as e: raise RuntimeError(f"SecureVault import failed: {e}") self._secure_vault = SecureVault() return self._secure_vault async def _agent_email_tool(self, args: Dict[str, Any]) -> ToolResult: payload = args or {} action = str(payload.get("action") or "").strip().lower() agent_id = str(payload.get("agent_id") or "sofiia") if not action: return ToolResult(success=False, result=None, error="Missing action") try: tool = self._get_agent_email_client(agent_id) if action == "create_inbox": return ToolResult(success=True, result=tool.create_inbox( username=payload.get("username"), domain=payload.get("domain"), display_name=payload.get("display_name"), )) if action == "list_inboxes": return ToolResult(success=True, result={"inboxes": tool.list_inboxes()}) if action == "delete_inbox": return ToolResult(success=True, result=tool.delete_inbox(inbox_id=payload.get("inbox_id"))) if action == "send": to = payload.get("to") if isinstance(payload.get("to"), list) else [] return ToolResult(success=True, result=tool.send( to=to, subject=str(payload.get("subject") or ""), body=str(payload.get("body") or ""), html=payload.get("html"), attachments=payload.get("attachments") if isinstance(payload.get("attachments"), list) else None, cc=payload.get("cc") if isinstance(payload.get("cc"), list) else None, bcc=payload.get("bcc") if isinstance(payload.get("bcc"), list) else None, inbox_id=payload.get("inbox_id"), )) if action == "receive": return ToolResult(success=True, result={ "emails": tool.receive( unread_only=bool(payload.get("unread_only", True)), limit=int(payload.get("limit", 20)), query=payload.get("query"), inbox_id=payload.get("inbox_id"), ) }) if action == "analyze_email": email_obj = payload.get("email") if isinstance(payload.get("email"), dict) else {} return ToolResult(success=True, result=tool.analyze_and_extract(email_obj)) return ToolResult(success=False, result=None, error=f"Unsupported action: {action}") except Exception as e: return ToolResult(success=False, result=None, error=f"Agent email tool error: {e}") async def _browser_tool(self, args: Dict[str, Any]) -> ToolResult: payload = args or {} action = str(payload.get("action") or "").strip().lower() agent_id = str(payload.get("agent_id") or "sofiia") if not action: return ToolResult(success=False, result=None, error="Missing action") try: tool = self._get_browser_client(agent_id) if action == "start_session": result = await asyncio.to_thread( tool.start_session, headless=payload.get("headless"), proxy=payload.get("proxy"), stealth=payload.get("stealth"), restore_existing=bool(payload.get("restore_existing", True)), ) return ToolResult(success=True, result=result) if action == "restore_session": result = await asyncio.to_thread(tool.restore_session, agent_id) return ToolResult(success=True, result=result) if action == "close_session": result = await asyncio.to_thread(tool.close_session) return ToolResult(success=True, result=result) if action == "goto": result = await asyncio.to_thread(tool.goto, str(payload.get("url") or "")) return ToolResult(success=True, result=result) if action == "act": result = await asyncio.to_thread(tool.act, str(payload.get("instruction") or "")) return ToolResult(success=True, result=result) if action == "extract": result = await asyncio.to_thread( tool.extract, instruction=str(payload.get("instruction") or ""), schema=payload.get("schema") if isinstance(payload.get("schema"), dict) else None, ) return ToolResult(success=True, result=result) if action == "observe": actions = await asyncio.to_thread(tool.observe, instruction=payload.get("instruction")) return ToolResult(success=True, result={"actions": actions}) if action == "screenshot": shot = await asyncio.to_thread(tool.screenshot) if isinstance(shot, bytes): return ToolResult(success=True, result={"status": "ok"}, file_base64=self._b64_from_bytes(shot), file_name="screenshot.png", file_mime="image/png") return ToolResult(success=True, result={"path": shot}) if action == "fill_form": fields = payload.get("fields") if isinstance(payload.get("fields"), list) else [] result = await asyncio.to_thread(tool.fill_form, fields) return ToolResult(success=True, result=result) if action == "wait_for": found = await asyncio.to_thread( tool.wait_for, str(payload.get("selector_or_text") or ""), timeout=int(payload.get("timeout", 10)), ) return ToolResult(success=True, result={"found": found}) if action == "get_current_url": url = await asyncio.to_thread(tool.get_current_url) return ToolResult(success=True, result={"url": url}) if action == "get_page_text": text = await asyncio.to_thread(tool.get_page_text) return ToolResult(success=True, result={"text": text}) return ToolResult(success=False, result=None, error=f"Unsupported action: {action}") except Exception as e: return ToolResult(success=False, result=None, error=f"Browser tool error: {e}") async def _safe_code_executor_tool(self, args: Dict[str, Any]) -> ToolResult: payload = args or {} action = str(payload.get("action") or "").strip().lower() if not action: return ToolResult(success=False, result=None, error="Missing action") try: executor = self._get_safe_executor() if action == "validate": language = str(payload.get("language") or "") code = str(payload.get("code") or "") err = executor.validate(language, code) return ToolResult(success=err is None, result={"valid": err is None, "error": err}) if action == "execute": return ToolResult(success=True, result=executor.execute( language=str(payload.get("language") or ""), code=str(payload.get("code") or ""), stdin=payload.get("stdin"), limits=payload.get("limits") if isinstance(payload.get("limits"), dict) else None, context={"agent_id": payload.get("agent_id")}, )) if action == "execute_async": job_id = executor.execute_async( language=str(payload.get("language") or ""), code=str(payload.get("code") or ""), stdin=payload.get("stdin"), limits=payload.get("limits") if isinstance(payload.get("limits"), dict) else None, context={"agent_id": payload.get("agent_id")}, ) return ToolResult(success=True, result={"job_id": job_id}) if action == "get_job_result": return ToolResult(success=True, result={"job": executor.get_job_result(str(payload.get("job_id") or ""))}) if action == "get_stats": return ToolResult(success=True, result=executor.get_stats()) if action == "kill_process": ok = executor.kill_process(str(payload.get("execution_id") or "")) return ToolResult(success=ok, result={"killed": ok}, error=None if ok else "execution_id not found") return ToolResult(success=False, result=None, error=f"Unsupported action: {action}") except Exception as e: return ToolResult(success=False, result=None, error=f"Safe code executor error: {e}") async def _secure_vault_tool(self, args: Dict[str, Any]) -> ToolResult: payload = args or {} action = str(payload.get("action") or "").strip().lower() agent_id = str(payload.get("agent_id") or "sofiia") if not action: return ToolResult(success=False, result=None, error="Missing action") try: vault = self._get_secure_vault() if action == "store": return ToolResult(success=True, result=vault.store( agent_id=agent_id, service=str(payload.get("service") or ""), credential_name=str(payload.get("credential_name") or ""), value=payload.get("value"), ttl_seconds=int(payload["ttl_seconds"]) if payload.get("ttl_seconds") is not None else None, )) if action == "get": value = vault.get( agent_id=agent_id, service=str(payload.get("service") or ""), credential_name=str(payload.get("credential_name") or ""), ) return ToolResult(success=True, result={"value": value, "found": value is not None}) if action == "delete": return ToolResult(success=True, result=vault.delete( agent_id=agent_id, service=str(payload.get("service") or ""), credential_name=str(payload.get("credential_name") or ""), )) if action == "list": listed = vault.list(agent_id=agent_id, service=payload.get("service")) key = "credentials" if payload.get("service") else "services" return ToolResult(success=True, result={key: listed}) if action == "check_expiring": return ToolResult(success=True, result={"items": vault.check_expiring(agent_id, days=int(payload.get("days", 7)))}) if action == "vacuum": return ToolResult(success=True, result=vault.vacuum(agent_id)) return ToolResult(success=False, result=None, error=f"Unsupported action: {action}") except Exception as e: return ToolResult(success=False, result=None, error=f"Secure vault tool error: {e}") async def _kb_tool(self, args: Dict[str, Any]) -> ToolResult: """ Knowledge Base Tool (read-only). Actions: search, snippets, open, sources. """ payload = args or {} action = str(payload.get("action") or "search").strip().lower() params = payload.get("params") if isinstance(payload.get("params"), dict) else {} repo_root = Path(os.getenv("REPO_ROOT", "/app")).resolve() default_paths = ["docs", "runbooks", "ops", "adr", "specs"] excluded_dirs = {"node_modules", "vendor", "dist", "build", ".git", "__pycache__", ".pytest_cache", "venv", ".venv"} allowed_ext = {".md", ".txt", ".yaml", ".yml", ".json"} def _safe_roots(paths: Any) -> List[Path]: roots: List[Path] = [] rels = paths if isinstance(paths, list) and paths else default_paths for rel in rels: if not isinstance(rel, str) or not rel.strip(): continue cand = (repo_root / rel.strip()).resolve() if str(cand).startswith(str(repo_root)) and cand.exists() and cand.is_dir(): roots.append(cand) return roots def _is_excluded(path: Path) -> bool: return any(part in excluded_dirs for part in path.parts) def _iter_candidates(paths: Any, file_glob: str) -> List[Path]: roots = _safe_roots(paths) out: List[Path] = [] pattern = file_glob or "**/*.md" for root in roots: try: for p in root.glob(pattern): if not p.is_file(): continue if p.suffix.lower() not in allowed_ext: continue if _is_excluded(p): continue out.append(p) except Exception: continue return out def _read_text(path: Path, max_bytes: int = 120000) -> str: try: return path.read_text(encoding="utf-8", errors="ignore")[:max_bytes] except Exception: return "" def _tokens(query: str) -> List[str]: return [t for t in re.findall(r"\w+", (query or "").lower()) if len(t) > 1] def _rel(path: Path) -> str: return str(path.resolve().relative_to(repo_root)) if action == "sources": rows = [] for root in _safe_roots(params.get("paths")): count = 0 for p in root.rglob("*"): if p.is_file() and p.suffix.lower() in allowed_ext and not _is_excluded(p): count += 1 rows.append({"path": _rel(root), "file_count": count, "status": "indexed"}) return ToolResult(success=True, result={"summary": f"Found {len(rows)} indexed sources", "sources": rows, "allowed_paths": default_paths}) if action == "open": raw_path = str(params.get("path") or "").strip() if not raw_path: return ToolResult(success=False, result=None, error="path is required") target = (repo_root / raw_path).resolve() if not str(target).startswith(str(repo_root)): return ToolResult(success=False, result=None, error="Path traversal blocked") if not any(str(target).startswith(str(root)) for root in _safe_roots(default_paths)): return ToolResult(success=False, result=None, error="Path not in allowed directories") if not target.exists() or not target.is_file(): return ToolResult(success=False, result=None, error="File not found") start_line = max(1, int(params.get("start_line", 1))) end_line_raw = params.get("end_line") end_line = int(end_line_raw) if end_line_raw is not None else None max_bytes = int(params.get("max_bytes", 200000)) content = _read_text(target, max_bytes=max_bytes) lines = content.splitlines() sliced = lines[start_line - 1:end_line] if end_line else lines[start_line - 1:] return ToolResult( success=True, result={ "path": _rel(target), "start_line": start_line, "end_line": end_line or (start_line + len(sliced) - 1), "total_lines": len(lines), "content": "\n".join(sliced), "truncated": len(content) >= max_bytes, }, ) if action in {"search", "snippets"}: query = str(params.get("query") or "").strip() if not query: return ToolResult(success=False, result=None, error="query is required") limit = max(1, min(int(params.get("limit", 20 if action == "search" else 8)), 100)) context_lines = max(0, min(int(params.get("context_lines", 4)), 20)) max_chars = max(100, min(int(params.get("max_chars_per_snippet", 800)), 5000)) candidates = _iter_candidates(params.get("paths"), params.get("file_glob", "**/*.md")) q_tokens = _tokens(query) ranked = [] for p in candidates: text = _read_text(p) if not text: continue lower = text.lower() score = float(sum(lower.count(tok) for tok in q_tokens)) if score <= 0: continue ranked.append((score, p, text)) ranked.sort(key=lambda x: x[0], reverse=True) if action == "search": results = [] for score, p, text in ranked[:limit]: highlights: List[str] = [] lower = text.lower() for tok in q_tokens[:5]: idx = lower.find(tok) if idx >= 0: s = max(0, idx - 30) e = min(len(text), idx + len(tok) + 30) highlights.append("..." + text[s:e].replace("\n", " ") + "...") results.append({"path": _rel(p), "score": round(score, 2), "highlights": highlights[:5]}) return ToolResult( success=True, result={"summary": f"Found {len(results)} results for '{query}'", "results": results, "query": query, "count": len(results)}, ) snippets = [] for score, p, text in ranked: lines = text.splitlines() lower_lines = [ln.lower() for ln in lines] for i, ln in enumerate(lower_lines): if any(tok in ln for tok in q_tokens): s = max(0, i - context_lines) e = min(len(lines), i + context_lines + 1) snippet = "\n".join(lines[s:e]) if len(snippet) > max_chars: snippet = snippet[:max_chars] + "..." snippets.append({"path": _rel(p), "score": round(score, 2), "lines": f"L{s+1}-L{e}", "text": snippet}) if len(snippets) >= limit: break if len(snippets) >= limit: break return ToolResult( success=True, result={"summary": f"Found {len(snippets)} snippets for '{query}'", "results": snippets, "query": query, "count": len(snippets)}, ) return ToolResult(success=False, result=None, error=f"Unknown action: {action}") def _file_csv_create(self, args: Dict[str, Any]) -> ToolResult: file_name = self._sanitize_file_name(args.get("file_name"), "export.csv", force_ext=".csv") headers = args.get("headers") or [] rows_raw = args.get("rows") or [] rows = self._normalize_rows(rows_raw, headers=headers if headers else None) if rows and not headers and isinstance(rows_raw[0], dict): headers = list(rows_raw[0].keys()) rows = self._normalize_rows(rows_raw, headers=headers) sio = StringIO(newline="") writer = csv.writer(sio) if headers: writer.writerow(headers) for row in rows: writer.writerow(row) data = sio.getvalue().encode("utf-8") return ToolResult( success=True, result={"message": f"CSV created: {file_name}"}, file_base64=self._b64_from_bytes(data), file_name=file_name, file_mime="text/csv", ) def _file_csv_update(self, args: Dict[str, Any]) -> ToolResult: src_b64 = args.get("file_base64") if not src_b64: return ToolResult(success=False, result=None, error="file_base64 is required for csv_update") file_name = self._sanitize_file_name(args.get("file_name"), "updated.csv", force_ext=".csv") operation = str(args.get("operation") or "append").strip().lower() if operation not in {"append", "replace"}: return ToolResult(success=False, result=None, error="operation must be append|replace") headers = args.get("headers") or [] rows_raw = args.get("rows") or [] rows = self._normalize_rows(rows_raw, headers=headers if headers else None) if rows and not headers and isinstance(rows_raw[0], dict): headers = list(rows_raw[0].keys()) rows = self._normalize_rows(rows_raw, headers=headers) existing_rows: List[List[str]] = [] text = self._bytes_from_b64(src_b64).decode("utf-8") if text.strip(): existing_rows = [list(r) for r in csv.reader(StringIO(text))] out_rows: List[List[Any]] = [] if operation == "replace": if headers: out_rows.append(headers) out_rows.extend(rows) else: if existing_rows: out_rows.extend(existing_rows) elif headers: out_rows.append(headers) out_rows.extend(rows) sio = StringIO(newline="") writer = csv.writer(sio) for row in out_rows: writer.writerow(row) data = sio.getvalue().encode("utf-8") return ToolResult( success=True, result={"message": f"CSV updated: {file_name}"}, file_base64=self._b64_from_bytes(data), file_name=file_name, file_mime="text/csv", ) @staticmethod def _rows_to_objects(rows_raw: Any, headers: Optional[List[str]] = None) -> List[Dict[str, Any]]: if not isinstance(rows_raw, list): return [] result: List[Dict[str, Any]] = [] for idx, row in enumerate(rows_raw): if isinstance(row, dict): result.append(dict(row)) continue if isinstance(row, list): if headers: obj = {str(headers[i]): row[i] if i < len(row) else None for i in range(len(headers))} else: obj = {f"col_{i+1}": v for i, v in enumerate(row)} result.append(obj) continue key = headers[0] if headers else "value" result.append({str(key): row}) return result def _file_ods_create(self, args: Dict[str, Any]) -> ToolResult: from odf.opendocument import OpenDocumentSpreadsheet from odf.table import Table, TableCell, TableRow from odf.text import P file_name = self._sanitize_file_name(args.get("file_name"), "sheet.ods", force_ext=".ods") headers = args.get("headers") or [] rows_raw = args.get("rows") or [] rows = self._normalize_rows(rows_raw, headers=headers if headers else None) if rows and not headers and isinstance(rows_raw[0], dict): headers = list(rows_raw[0].keys()) rows = self._normalize_rows(rows_raw, headers=headers) doc = OpenDocumentSpreadsheet() table = Table(name=str(args.get("sheet_name") or "Sheet1")) if headers: hrow = TableRow() for value in headers: cell = TableCell(valuetype="string") cell.addElement(P(text=str(value))) hrow.addElement(cell) table.addElement(hrow) for row in rows: trow = TableRow() for value in row: cell = TableCell(valuetype="string") cell.addElement(P(text="" if value is None else str(value))) trow.addElement(cell) table.addElement(trow) doc.spreadsheet.addElement(table) with tempfile.NamedTemporaryFile(suffix=".ods") as tmp: doc.save(tmp.name) tmp.seek(0) payload = tmp.read() return ToolResult( success=True, result={"message": f"ODS created: {file_name}"}, file_base64=self._b64_from_bytes(payload), file_name=file_name, file_mime="application/vnd.oasis.opendocument.spreadsheet", ) def _file_ods_update(self, args: Dict[str, Any]) -> ToolResult: from odf.opendocument import OpenDocumentSpreadsheet, load from odf.table import Table, TableCell, TableRow from odf.text import P src_b64 = args.get("file_base64") if not src_b64: return ToolResult(success=False, result=None, error="file_base64 is required for ods_update") file_name = self._sanitize_file_name(args.get("file_name"), "updated.ods", force_ext=".ods") operation = str(args.get("operation") or "append").strip().lower() if operation not in {"append", "replace"}: return ToolResult(success=False, result=None, error="operation must be append|replace") headers = args.get("headers") or [] rows_raw = args.get("rows") or [] rows = self._normalize_rows(rows_raw, headers=headers if headers else None) if rows and not headers and isinstance(rows_raw[0], dict): headers = list(rows_raw[0].keys()) rows = self._normalize_rows(rows_raw, headers=headers) with tempfile.NamedTemporaryFile(suffix=".ods") as src: src.write(self._bytes_from_b64(src_b64)) src.flush() doc = load(src.name) # Rebuild first table to keep update deterministic. tables = doc.spreadsheet.getElementsByType(Table) existing: List[List[str]] = [] if tables and operation == "append": first = tables[0] for r in first.getElementsByType(TableRow): vals = [] for c in r.getElementsByType(TableCell): text_nodes = c.getElementsByType(P) vals.append("".join((p.firstChild.data if p.firstChild else "") for p in text_nodes)) existing.append(vals) doc.spreadsheet.removeChild(first) elif tables: doc.spreadsheet.removeChild(tables[0]) table = Table(name=str(args.get("sheet_name") or "Sheet1")) out_rows: List[List[Any]] = [] if operation == "append" and existing: out_rows.extend(existing) out_rows.extend(rows) else: if headers: out_rows.append(headers) out_rows.extend(rows) for row in out_rows: trow = TableRow() for value in row: cell = TableCell(valuetype="string") cell.addElement(P(text="" if value is None else str(value))) trow.addElement(cell) table.addElement(trow) doc.spreadsheet.addElement(table) with tempfile.NamedTemporaryFile(suffix=".ods") as dst: doc.save(dst.name) dst.seek(0) payload = dst.read() return ToolResult( success=True, result={"message": f"ODS updated: {file_name}"}, file_base64=self._b64_from_bytes(payload), file_name=file_name, file_mime="application/vnd.oasis.opendocument.spreadsheet", ) def _file_parquet_create(self, args: Dict[str, Any]) -> ToolResult: import pyarrow as pa import pyarrow.parquet as pq file_name = self._sanitize_file_name(args.get("file_name"), "data.parquet", force_ext=".parquet") headers = args.get("headers") or [] rows_raw = args.get("rows") or [] objects = self._rows_to_objects(rows_raw, headers=headers if headers else None) table = pa.Table.from_pylist(objects if objects else [{"value": None}]) out = BytesIO() pq.write_table(table, out) payload = out.getvalue() return ToolResult( success=True, result={"message": f"Parquet created: {file_name}"}, file_base64=self._b64_from_bytes(payload), file_name=file_name, file_mime="application/vnd.apache.parquet", ) def _file_parquet_update(self, args: Dict[str, Any]) -> ToolResult: import pyarrow as pa import pyarrow.parquet as pq src_b64 = args.get("file_base64") if not src_b64: return ToolResult(success=False, result=None, error="file_base64 is required for parquet_update") file_name = self._sanitize_file_name(args.get("file_name"), "updated.parquet", force_ext=".parquet") operation = str(args.get("operation") or "append").strip().lower() if operation not in {"append", "replace"}: return ToolResult(success=False, result=None, error="operation must be append|replace") headers = args.get("headers") or [] rows_raw = args.get("rows") or [] new_rows = self._rows_to_objects(rows_raw, headers=headers if headers else None) existing_rows: List[Dict[str, Any]] = [] if operation == "append": table = pq.read_table(BytesIO(self._bytes_from_b64(src_b64))) existing_rows = table.to_pylist() merged = new_rows if operation == "replace" else (existing_rows + new_rows) table = pa.Table.from_pylist(merged if merged else [{"value": None}]) out = BytesIO() pq.write_table(table, out) payload = out.getvalue() return ToolResult( success=True, result={"message": f"Parquet updated: {file_name}"}, file_base64=self._b64_from_bytes(payload), file_name=file_name, file_mime="application/vnd.apache.parquet", ) def _file_json_export(self, args: Dict[str, Any]) -> ToolResult: file_name = self._sanitize_file_name(args.get("file_name"), "export.json", force_ext=".json") content = args.get("content") indent = int(args.get("indent") or 2) payload = json.dumps(content, indent=indent, ensure_ascii=False).encode("utf-8") return ToolResult( success=True, result={"message": f"JSON exported: {file_name}"}, file_base64=self._b64_from_bytes(payload), file_name=file_name, file_mime="application/json", ) @staticmethod def _stringify_text_payload(args: Dict[str, Any], key: str = "text") -> str: value = args.get(key) if value is None and key != "content": value = args.get("content") if value is None: return "" if isinstance(value, (dict, list)): return json.dumps(value, ensure_ascii=False, indent=2) return str(value) def _file_text_create(self, args: Dict[str, Any]) -> ToolResult: file_name = self._sanitize_file_name(args.get("file_name"), "note.txt", force_ext=".txt") text = self._stringify_text_payload(args, key="text") data = text.encode("utf-8") return ToolResult( success=True, result={"message": f"Text file created: {file_name}"}, file_base64=self._b64_from_bytes(data), file_name=file_name, file_mime="text/plain", ) def _file_text_update(self, args: Dict[str, Any]) -> ToolResult: src_b64 = args.get("file_base64") if not src_b64: return ToolResult(success=False, result=None, error="file_base64 is required for text_update") file_name = self._sanitize_file_name(args.get("file_name"), "updated.txt", force_ext=".txt") operation = str(args.get("operation") or "append").strip().lower() if operation not in {"append", "replace"}: return ToolResult(success=False, result=None, error="operation must be append|replace") incoming = self._stringify_text_payload(args, key="text") existing = self._bytes_from_b64(src_b64).decode("utf-8") updated = incoming if operation == "replace" else f"{existing}{incoming}" data = updated.encode("utf-8") return ToolResult( success=True, result={"message": f"Text file updated: {file_name}"}, file_base64=self._b64_from_bytes(data), file_name=file_name, file_mime="text/plain", ) def _file_markdown_create(self, args: Dict[str, Any]) -> ToolResult: file_name = self._sanitize_file_name(args.get("file_name"), "document.md", force_ext=".md") text = self._stringify_text_payload(args, key="text") data = text.encode("utf-8") return ToolResult( success=True, result={"message": f"Markdown created: {file_name}"}, file_base64=self._b64_from_bytes(data), file_name=file_name, file_mime="text/markdown", ) def _file_markdown_update(self, args: Dict[str, Any]) -> ToolResult: src_b64 = args.get("file_base64") if not src_b64: return ToolResult(success=False, result=None, error="file_base64 is required for markdown_update") file_name = self._sanitize_file_name(args.get("file_name"), "updated.md", force_ext=".md") operation = str(args.get("operation") or "append").strip().lower() if operation not in {"append", "replace"}: return ToolResult(success=False, result=None, error="operation must be append|replace") incoming = self._stringify_text_payload(args, key="text") existing = self._bytes_from_b64(src_b64).decode("utf-8") updated = incoming if operation == "replace" else f"{existing}{incoming}" data = updated.encode("utf-8") return ToolResult( success=True, result={"message": f"Markdown updated: {file_name}"}, file_base64=self._b64_from_bytes(data), file_name=file_name, file_mime="text/markdown", ) def _file_xml_export(self, args: Dict[str, Any]) -> ToolResult: file_name = self._sanitize_file_name(args.get("file_name"), "export.xml", force_ext=".xml") xml = self._stringify_text_payload(args, key="xml") data = xml.encode("utf-8") return ToolResult( success=True, result={"message": f"XML exported: {file_name}"}, file_base64=self._b64_from_bytes(data), file_name=file_name, file_mime="application/xml", ) def _file_html_export(self, args: Dict[str, Any]) -> ToolResult: file_name = self._sanitize_file_name(args.get("file_name"), "export.html", force_ext=".html") html = self._stringify_text_payload(args, key="html") data = html.encode("utf-8") return ToolResult( success=True, result={"message": f"HTML exported: {file_name}"}, file_base64=self._b64_from_bytes(data), file_name=file_name, file_mime="text/html", ) @staticmethod def _image_format_for_name(file_name: str, fallback: str = "PNG") -> str: suffix = PurePath(file_name).suffix.lower() mapping = { ".png": "PNG", ".jpg": "JPEG", ".jpeg": "JPEG", ".webp": "WEBP", ".gif": "GIF", ".bmp": "BMP", ".tif": "TIFF", ".tiff": "TIFF", } return mapping.get(suffix, fallback) @staticmethod def _mime_for_image_format(fmt: str) -> str: mapping = { "PNG": "image/png", "JPEG": "image/jpeg", "WEBP": "image/webp", "GIF": "image/gif", "BMP": "image/bmp", "TIFF": "image/tiff", } return mapping.get(fmt.upper(), "application/octet-stream") def _file_image_create(self, args: Dict[str, Any]) -> ToolResult: from PIL import Image, ImageDraw file_name = self._sanitize_file_name(args.get("file_name"), "image.png") fmt = self._image_format_for_name(file_name, fallback=str(args.get("format") or "PNG")) width = max(1, int(args.get("width") or 1024)) height = max(1, int(args.get("height") or 1024)) color = args.get("background_color") or args.get("color") or "white" image = Image.new("RGB", (width, height), color=color) text = args.get("text") if text: draw = ImageDraw.Draw(image) draw.text((20, 20), str(text), fill=args.get("text_color") or "black") out = BytesIO() image.save(out, format=fmt) payload = out.getvalue() return ToolResult( success=True, result={"message": f"Image created: {file_name}"}, file_base64=self._b64_from_bytes(payload), file_name=file_name, file_mime=self._mime_for_image_format(fmt), ) def _file_image_edit(self, args: Dict[str, Any]) -> ToolResult: from PIL import Image, ImageDraw src_b64 = args.get("file_base64") operations = args.get("operations") or [] if not src_b64: return ToolResult(success=False, result=None, error="file_base64 is required for image_edit") if not isinstance(operations, list) or not operations: return ToolResult(success=False, result=None, error="operations must be non-empty array") file_name = self._sanitize_file_name(args.get("file_name"), "edited.png") fmt = self._image_format_for_name(file_name, fallback=str(args.get("format") or "PNG")) image = Image.open(BytesIO(self._bytes_from_b64(src_b64))) for op in operations: if not isinstance(op, dict): return ToolResult(success=False, result=None, error="Each operation must be object") op_type = str(op.get("type") or "").strip().lower() if op_type == "resize": width = max(1, int(op.get("width") or image.width)) height = max(1, int(op.get("height") or image.height)) image = image.resize((width, height)) elif op_type == "crop": left = int(op.get("left") or 0) top = int(op.get("top") or 0) right = int(op.get("right") or image.width) bottom = int(op.get("bottom") or image.height) image = image.crop((left, top, right, bottom)) elif op_type == "rotate": angle = float(op.get("angle") or 0) image = image.rotate(angle, expand=bool(op.get("expand", True))) elif op_type == "flip_horizontal": image = image.transpose(Image.FLIP_LEFT_RIGHT) elif op_type == "flip_vertical": image = image.transpose(Image.FLIP_TOP_BOTTOM) elif op_type == "draw_text": draw = ImageDraw.Draw(image) x = int(op.get("x") or 0) y = int(op.get("y") or 0) draw.text((x, y), str(op.get("text") or ""), fill=op.get("color") or "black") else: return ToolResult(success=False, result=None, error=f"Unsupported image_edit operation: {op_type}") out = BytesIO() image.save(out, format=fmt) payload = out.getvalue() return ToolResult( success=True, result={"message": f"Image edited: {file_name}"}, file_base64=self._b64_from_bytes(payload), file_name=file_name, file_mime=self._mime_for_image_format(fmt), ) def _file_image_convert(self, args: Dict[str, Any]) -> ToolResult: from PIL import Image src_b64 = args.get("file_base64") if not src_b64: return ToolResult(success=False, result=None, error="file_base64 is required for image_convert") file_name = self._sanitize_file_name(args.get("file_name"), "converted.png") fmt = str(args.get("target_format") or self._image_format_for_name(file_name)).upper() if fmt == "JPG": fmt = "JPEG" image = Image.open(BytesIO(self._bytes_from_b64(src_b64))) if fmt in {"JPEG"} and image.mode not in {"RGB", "L"}: image = image.convert("RGB") out = BytesIO() save_kwargs: Dict[str, Any] = {} if fmt in {"JPEG", "WEBP"} and args.get("quality") is not None: save_kwargs["quality"] = int(args.get("quality")) image.save(out, format=fmt, **save_kwargs) payload = out.getvalue() return ToolResult( success=True, result={"message": f"Image converted: {file_name}"}, file_base64=self._b64_from_bytes(payload), file_name=file_name, file_mime=self._mime_for_image_format(fmt), ) def _file_image_bundle(self, args: Dict[str, Any]) -> ToolResult: file_name = self._sanitize_file_name(args.get("file_name"), "images.zip", force_ext=".zip") images = args.get("images") or args.get("entries") or [] if not isinstance(images, list) or not images: return ToolResult(success=False, result=None, error="images must be non-empty array") out = BytesIO() with ZipFile(out, mode="w", compression=ZIP_DEFLATED) as zf: for idx, item in enumerate(images, start=1): if not isinstance(item, dict): return ToolResult(success=False, result=None, error=f"images[{idx-1}] must be object") src_b64 = item.get("file_base64") if not src_b64: return ToolResult(success=False, result=None, error=f"images[{idx-1}].file_base64 is required") in_name = self._sanitize_file_name(item.get("file_name"), f"image_{idx}.bin") zf.writestr(in_name, self._bytes_from_b64(src_b64)) payload = out.getvalue() return ToolResult( success=True, result={"message": f"Image bundle created: {file_name}"}, file_base64=self._b64_from_bytes(payload), file_name=file_name, file_mime="application/zip", ) @staticmethod def _strip_ns(tag: str) -> str: return tag.split("}", 1)[1] if "}" in tag else tag @staticmethod def _safe_int(value: Any, default: int = 0) -> int: try: text = str(value).strip() if text.endswith("px"): text = text[:-2] return int(float(text)) except Exception: return default @staticmethod def _safe_float(value: Any, default: float = 0.0) -> float: try: text = str(value).strip() if text.endswith("px"): text = text[:-2] return float(text) except Exception: return default @staticmethod def _svg_style_map(elem: Any) -> Dict[str, str]: style = str(elem.attrib.get("style") or "") out: Dict[str, str] = {} for chunk in style.split(";"): if ":" not in chunk: continue k, v = chunk.split(":", 1) out[k.strip()] = v.strip() return out def _svg_paint(self, elem: Any, key: str, default: Optional[str]) -> Optional[str]: style = self._svg_style_map(elem) value = elem.attrib.get(key, style.get(key, default)) if value is None: return None text = str(value).strip() if not text or text.lower() == "none": return None return text @staticmethod def _svg_color(value: Optional[str], fallback: Optional[tuple[int, int, int]] = None) -> Optional[tuple[int, int, int]]: if value is None: return fallback try: from PIL import ImageColor return ImageColor.getrgb(value) except Exception: return fallback @staticmethod def _svg_points(raw: Any) -> List[tuple[float, float]]: text = str(raw or "").replace(",", " ") nums: List[float] = [] for token in text.split(): try: nums.append(float(token)) except Exception: continue pts: List[tuple[float, float]] = [] for i in range(0, len(nums) - 1, 2): pts.append((nums[i], nums[i + 1])) return pts def _file_svg_export(self, args: Dict[str, Any]) -> ToolResult: file_name = self._sanitize_file_name(args.get("file_name"), "image.svg", force_ext=".svg") svg_raw = args.get("svg") if svg_raw is not None: payload = str(svg_raw).encode("utf-8") return ToolResult( success=True, result={"message": f"SVG exported: {file_name}"}, file_base64=self._b64_from_bytes(payload), file_name=file_name, file_mime="image/svg+xml", ) width = max(1, int(args.get("width") or 1024)) height = max(1, int(args.get("height") or 1024)) bg = str(args.get("background_color") or "white") text = xml_escape(str(args.get("text") or "")) text_color = str(args.get("text_color") or "black") text_x = int(args.get("text_x") or 20) text_y = int(args.get("text_y") or 40) # Minimal deterministic SVG template for safe generation. svg = ( f'' f'' f'{text}' f"" ) payload = svg.encode("utf-8") return ToolResult( success=True, result={"message": f"SVG exported: {file_name}"}, file_base64=self._b64_from_bytes(payload), file_name=file_name, file_mime="image/svg+xml", ) def _file_svg_to_png(self, args: Dict[str, Any]) -> ToolResult: from PIL import Image, ImageDraw file_name = self._sanitize_file_name(args.get("file_name"), "converted.png", force_ext=".png") src_b64 = args.get("file_base64") svg_text = args.get("svg") if src_b64: svg_text = self._bytes_from_b64(src_b64).decode("utf-8", errors="ignore") if not svg_text: return ToolResult(success=False, result=None, error="file_base64 or svg is required for svg_to_png") try: root = ET.fromstring(svg_text) except Exception as exc: return ToolResult(success=False, result=None, error=f"Invalid SVG: {exc}") width = self._safe_int(root.attrib.get("width"), 1024) height = self._safe_int(root.attrib.get("height"), 1024) width = max(1, width) height = max(1, height) image = Image.new("RGBA", (width, height), color="white") draw = ImageDraw.Draw(image) for elem in root.iter(): tag = self._strip_ns(elem.tag) if tag == "rect": x = self._safe_int(elem.attrib.get("x"), 0) y = self._safe_int(elem.attrib.get("y"), 0) w = self._safe_int(elem.attrib.get("width"), width) h = self._safe_int(elem.attrib.get("height"), height) fill = self._svg_paint(elem, "fill", "white") stroke = self._svg_paint(elem, "stroke", None) stroke_width = max(1, self._safe_int(elem.attrib.get("stroke-width"), 1)) color = self._svg_color(fill, (255, 255, 255) if fill else None) outline = None if stroke: outline = self._svg_color(stroke, (0, 0, 0)) draw.rectangle([x, y, x + max(0, w), y + max(0, h)], fill=color, outline=outline, width=stroke_width) elif tag == "circle": cx = self._safe_float(elem.attrib.get("cx"), width / 2.0) cy = self._safe_float(elem.attrib.get("cy"), height / 2.0) r = max(0.0, self._safe_float(elem.attrib.get("r"), 0.0)) fill = self._svg_paint(elem, "fill", None) stroke = self._svg_paint(elem, "stroke", None) stroke_width = max(1, self._safe_int(elem.attrib.get("stroke-width"), 1)) fill_color = self._svg_color(fill, None) outline = self._svg_color(stroke, None) draw.ellipse([cx - r, cy - r, cx + r, cy + r], fill=fill_color, outline=outline, width=stroke_width) elif tag == "ellipse": cx = self._safe_float(elem.attrib.get("cx"), width / 2.0) cy = self._safe_float(elem.attrib.get("cy"), height / 2.0) rx = max(0.0, self._safe_float(elem.attrib.get("rx"), 0.0)) ry = max(0.0, self._safe_float(elem.attrib.get("ry"), 0.0)) fill = self._svg_paint(elem, "fill", None) stroke = self._svg_paint(elem, "stroke", None) stroke_width = max(1, self._safe_int(elem.attrib.get("stroke-width"), 1)) fill_color = self._svg_color(fill, None) outline = self._svg_color(stroke, None) draw.ellipse([cx - rx, cy - ry, cx + rx, cy + ry], fill=fill_color, outline=outline, width=stroke_width) elif tag == "line": x1 = self._safe_float(elem.attrib.get("x1"), 0.0) y1 = self._safe_float(elem.attrib.get("y1"), 0.0) x2 = self._safe_float(elem.attrib.get("x2"), 0.0) y2 = self._safe_float(elem.attrib.get("y2"), 0.0) stroke = self._svg_paint(elem, "stroke", "black") stroke_width = max(1, self._safe_int(elem.attrib.get("stroke-width"), 1)) color = self._svg_color(stroke, (0, 0, 0)) or (0, 0, 0) draw.line([(x1, y1), (x2, y2)], fill=color, width=stroke_width) elif tag == "polyline": points = self._svg_points(elem.attrib.get("points")) if len(points) >= 2: stroke = self._svg_paint(elem, "stroke", "black") stroke_width = max(1, self._safe_int(elem.attrib.get("stroke-width"), 1)) color = self._svg_color(stroke, (0, 0, 0)) or (0, 0, 0) draw.line(points, fill=color, width=stroke_width) elif tag == "polygon": points = self._svg_points(elem.attrib.get("points")) if len(points) >= 3: fill = self._svg_paint(elem, "fill", None) stroke = self._svg_paint(elem, "stroke", None) stroke_width = max(1, self._safe_int(elem.attrib.get("stroke-width"), 1)) fill_color = self._svg_color(fill, None) outline = self._svg_color(stroke, None) draw.polygon(points, fill=fill_color, outline=outline) # Pillow polygon has no width support; emulate thicker stroke if outline and stroke_width > 1: draw.line(points + [points[0]], fill=outline, width=stroke_width) elif tag == "text": x = self._safe_int(elem.attrib.get("x"), 0) y = self._safe_int(elem.attrib.get("y"), 0) fill = self._svg_paint(elem, "fill", "black") color = self._svg_color(fill, (0, 0, 0)) or (0, 0, 0) draw.text((x, y), elem.text or "", fill=color) out = BytesIO() image.convert("RGB").save(out, format="PNG") payload = out.getvalue() return ToolResult( success=True, result={"message": f"SVG converted to PNG: {file_name}"}, file_base64=self._b64_from_bytes(payload), file_name=file_name, file_mime="image/png", ) def _file_yaml_export(self, args: Dict[str, Any]) -> ToolResult: file_name = self._sanitize_file_name(args.get("file_name"), "export.yaml", force_ext=".yaml") content = args.get("content") payload = json.dumps(content, ensure_ascii=False, indent=2).encode("utf-8") try: import yaml payload = yaml.safe_dump(content, allow_unicode=True, sort_keys=False).encode("utf-8") except Exception: # Fallback to JSON serialization if PyYAML fails. pass return ToolResult( success=True, result={"message": f"YAML exported: {file_name}"}, file_base64=self._b64_from_bytes(payload), file_name=file_name, file_mime="application/x-yaml", ) def _file_zip_bundle(self, args: Dict[str, Any]) -> ToolResult: file_name = self._sanitize_file_name(args.get("file_name"), "bundle.zip", force_ext=".zip") entries = args.get("entries") or [] if not isinstance(entries, list) or not entries: return ToolResult(success=False, result=None, error="entries must be non-empty array") out = BytesIO() with ZipFile(out, mode="w", compression=ZIP_DEFLATED) as zf: for idx, entry in enumerate(entries, start=1): if not isinstance(entry, dict): return ToolResult(success=False, result=None, error=f"entry[{idx-1}] must be object") ename = self._sanitize_file_name(entry.get("file_name"), f"file_{idx}.bin") if entry.get("file_base64"): zf.writestr(ename, self._bytes_from_b64(entry["file_base64"])) elif "text" in entry: zf.writestr(ename, str(entry["text"]).encode("utf-8")) elif "content" in entry: zf.writestr(ename, json.dumps(entry["content"], ensure_ascii=False, indent=2).encode("utf-8")) else: return ToolResult( success=False, result=None, error=f"entry[{idx-1}] requires file_base64|text|content", ) return ToolResult( success=True, result={"message": f"ZIP bundle created: {file_name}"}, file_base64=self._b64_from_bytes(out.getvalue()), file_name=file_name, file_mime="application/zip", ) def _file_excel_create(self, args: Dict[str, Any]) -> ToolResult: import openpyxl file_name = self._sanitize_file_name(args.get("file_name"), "report.xlsx", force_ext=".xlsx") sheets = args.get("sheets") wb = openpyxl.Workbook() wb.remove(wb.active) created = False if isinstance(sheets, list) and sheets: for idx, sheet in enumerate(sheets, start=1): if not isinstance(sheet, dict): return ToolResult(success=False, result=None, error=f"sheets[{idx-1}] must be object") sheet_name = str(sheet.get("name") or f"Sheet{idx}")[:31] headers = sheet.get("headers") or [] rows_raw = sheet.get("rows") or [] rows = self._normalize_rows(rows_raw, headers=headers if headers else None) if rows and not headers and isinstance(rows_raw[0], dict): headers = list(rows_raw[0].keys()) rows = self._normalize_rows(rows_raw, headers=headers) ws = wb.create_sheet(title=sheet_name) self._append_sheet_data(ws, headers, rows) created = True else: sheet_name = str(args.get("sheet_name") or "Sheet1")[:31] headers = args.get("headers") or [] rows_raw = args.get("rows") or [] rows = self._normalize_rows(rows_raw, headers=headers if headers else None) if rows and not headers and isinstance(rows_raw[0], dict): headers = list(rows_raw[0].keys()) rows = self._normalize_rows(rows_raw, headers=headers) ws = wb.create_sheet(title=sheet_name) self._append_sheet_data(ws, headers, rows) created = True if not created: wb.create_sheet(title="Sheet1") out = BytesIO() wb.save(out) return ToolResult( success=True, result={"message": f"Excel created: {file_name}"}, file_base64=self._b64_from_bytes(out.getvalue()), file_name=file_name, file_mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ) def _file_excel_update(self, args: Dict[str, Any]) -> ToolResult: import openpyxl src_b64 = args.get("file_base64") operations = args.get("operations") or [] if not src_b64: return ToolResult(success=False, result=None, error="file_base64 is required for excel_update") if not isinstance(operations, list) or not operations: return ToolResult(success=False, result=None, error="operations must be non-empty array") file_name = self._sanitize_file_name(args.get("file_name"), "updated.xlsx", force_ext=".xlsx") wb = openpyxl.load_workbook(filename=BytesIO(self._bytes_from_b64(src_b64))) for op in operations: if not isinstance(op, dict): return ToolResult(success=False, result=None, error="Each operation must be object") op_type = str(op.get("type") or "").strip().lower() if op_type == "append_rows": sheet = str(op.get("sheet") or wb.sheetnames[0])[:31] if sheet not in wb.sheetnames: wb.create_sheet(title=sheet) ws = wb[sheet] rows_raw = op.get("rows") or [] header_row = [c.value for c in ws[1]] if ws.max_row >= 1 else [] rows = self._normalize_rows(rows_raw, headers=header_row if header_row else None) if rows and not header_row and isinstance(rows_raw[0], dict): header_row = list(rows_raw[0].keys()) ws.append(header_row) rows = self._normalize_rows(rows_raw, headers=header_row) for row in rows: ws.append(row) elif op_type == "set_cell": sheet = str(op.get("sheet") or wb.sheetnames[0])[:31] cell = op.get("cell") if not cell: return ToolResult(success=False, result=None, error="set_cell operation requires 'cell'") if sheet not in wb.sheetnames: wb.create_sheet(title=sheet) wb[sheet][str(cell)] = op.get("value", "") elif op_type == "replace_sheet": sheet = str(op.get("sheet") or wb.sheetnames[0])[:31] if sheet in wb.sheetnames: wb.remove(wb[sheet]) ws = wb.create_sheet(title=sheet) headers = op.get("headers") or [] rows_raw = op.get("rows") or [] rows = self._normalize_rows(rows_raw, headers=headers if headers else None) if rows and not headers and isinstance(rows_raw[0], dict): headers = list(rows_raw[0].keys()) rows = self._normalize_rows(rows_raw, headers=headers) self._append_sheet_data(ws, headers, rows) elif op_type == "rename_sheet": src = str(op.get("from") or "") dst = str(op.get("to") or "").strip() if not src or not dst: return ToolResult(success=False, result=None, error="rename_sheet requires 'from' and 'to'") if src not in wb.sheetnames: return ToolResult(success=False, result=None, error=f"Sheet not found: {src}") wb[src].title = dst[:31] else: return ToolResult(success=False, result=None, error=f"Unsupported excel_update operation: {op_type}") out = BytesIO() wb.save(out) return ToolResult( success=True, result={"message": f"Excel updated: {file_name}"}, file_base64=self._b64_from_bytes(out.getvalue()), file_name=file_name, file_mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ) def _file_docx_create(self, args: Dict[str, Any]) -> ToolResult: from docx import Document file_name = self._sanitize_file_name(args.get("file_name"), "document.docx", force_ext=".docx") doc = Document() title = args.get("title") if title: doc.add_heading(str(title), level=1) for item in args.get("paragraphs") or []: doc.add_paragraph(str(item)) for table in args.get("tables") or []: if not isinstance(table, dict): continue headers = [str(h) for h in (table.get("headers") or [])] rows_raw = table.get("rows") or [] rows = self._normalize_rows(rows_raw, headers=headers if headers else None) if rows and not headers and isinstance(rows_raw[0], dict): headers = list(rows_raw[0].keys()) rows = self._normalize_rows(rows_raw, headers=headers) total_rows = len(rows) + (1 if headers else 0) total_cols = len(headers) if headers else (len(rows[0]) if rows else 1) t = doc.add_table(rows=max(total_rows, 1), cols=max(total_cols, 1)) row_offset = 0 if headers: for idx, value in enumerate(headers): t.cell(0, idx).text = str(value) row_offset = 1 for ridx, row in enumerate(rows): for cidx, value in enumerate(row): if cidx < total_cols: t.cell(ridx + row_offset, cidx).text = str(value) out = BytesIO() doc.save(out) return ToolResult( success=True, result={"message": f"DOCX created: {file_name}"}, file_base64=self._b64_from_bytes(out.getvalue()), file_name=file_name, file_mime="application/vnd.openxmlformats-officedocument.wordprocessingml.document", ) def _file_docx_update(self, args: Dict[str, Any]) -> ToolResult: from docx import Document src_b64 = args.get("file_base64") operations = args.get("operations") or [] if not src_b64: return ToolResult(success=False, result=None, error="file_base64 is required for docx_update") if not isinstance(operations, list) or not operations: return ToolResult(success=False, result=None, error="operations must be non-empty array") file_name = self._sanitize_file_name(args.get("file_name"), "updated.docx", force_ext=".docx") doc = Document(BytesIO(self._bytes_from_b64(src_b64))) for op in operations: if not isinstance(op, dict): return ToolResult(success=False, result=None, error="Each operation must be object") op_type = str(op.get("type") or "").strip().lower() if op_type == "append_paragraph": doc.add_paragraph(str(op.get("text") or "")) elif op_type == "append_heading": level = int(op.get("level") or 1) level = max(1, min(level, 9)) doc.add_heading(str(op.get("text") or ""), level=level) elif op_type == "replace_text": old = str(op.get("old") or "") new = str(op.get("new") or "") if not old: return ToolResult(success=False, result=None, error="replace_text requires old") for p in doc.paragraphs: if old in p.text: p.text = p.text.replace(old, new) for table in doc.tables: for row in table.rows: for cell in row.cells: if old in cell.text: cell.text = cell.text.replace(old, new) elif op_type == "append_table": headers = [str(h) for h in (op.get("headers") or [])] rows_raw = op.get("rows") or [] rows = self._normalize_rows(rows_raw, headers=headers if headers else None) if rows and not headers and isinstance(rows_raw[0], dict): headers = list(rows_raw[0].keys()) rows = self._normalize_rows(rows_raw, headers=headers) total_rows = len(rows) + (1 if headers else 0) total_cols = len(headers) if headers else (len(rows[0]) if rows else 1) t = doc.add_table(rows=max(total_rows, 1), cols=max(total_cols, 1)) row_offset = 0 if headers: for idx, value in enumerate(headers): t.cell(0, idx).text = str(value) row_offset = 1 for ridx, row in enumerate(rows): for cidx, value in enumerate(row): if cidx < total_cols: t.cell(ridx + row_offset, cidx).text = str(value) else: return ToolResult(success=False, result=None, error=f"Unsupported docx_update operation: {op_type}") out = BytesIO() doc.save(out) return ToolResult( success=True, result={"message": f"DOCX updated: {file_name}"}, file_base64=self._b64_from_bytes(out.getvalue()), file_name=file_name, file_mime="application/vnd.openxmlformats-officedocument.wordprocessingml.document", ) def _file_pptx_create(self, args: Dict[str, Any]) -> ToolResult: from pptx import Presentation file_name = self._sanitize_file_name(args.get("file_name"), "slides.pptx", force_ext=".pptx") prs = Presentation() title = str(args.get("title") or "").strip() subtitle = str(args.get("subtitle") or "").strip() if title or subtitle: slide = prs.slides.add_slide(prs.slide_layouts[0]) if title and slide.shapes.title: slide.shapes.title.text = title if subtitle and len(slide.placeholders) > 1: slide.placeholders[1].text = subtitle for entry in args.get("slides") or []: if not isinstance(entry, dict): continue slide = prs.slides.add_slide(prs.slide_layouts[1]) if slide.shapes.title: slide.shapes.title.text = str(entry.get("title") or "") body = None if len(slide.placeholders) > 1: body = slide.placeholders[1].text_frame lines = entry.get("bullets") if lines is None: lines = entry.get("lines") if lines is None: lines = [entry.get("text")] if entry.get("text") is not None else [] if body is not None: body.clear() first = True for line in lines: if first: body.text = str(line) first = False else: p = body.add_paragraph() p.text = str(line) out = BytesIO() prs.save(out) return ToolResult( success=True, result={"message": f"PPTX created: {file_name}"}, file_base64=self._b64_from_bytes(out.getvalue()), file_name=file_name, file_mime="application/vnd.openxmlformats-officedocument.presentationml.presentation", ) def _file_pptx_update(self, args: Dict[str, Any]) -> ToolResult: from pptx import Presentation from pptx.util import Inches src_b64 = args.get("file_base64") operations = args.get("operations") or [] if not src_b64: return ToolResult(success=False, result=None, error="file_base64 is required for pptx_update") if not isinstance(operations, list) or not operations: return ToolResult(success=False, result=None, error="operations must be non-empty array") file_name = self._sanitize_file_name(args.get("file_name"), "updated.pptx", force_ext=".pptx") prs = Presentation(BytesIO(self._bytes_from_b64(src_b64))) for op in operations: if not isinstance(op, dict): return ToolResult(success=False, result=None, error="Each operation must be object") op_type = str(op.get("type") or "").strip().lower() if op_type == "append_slide": layout_idx = int(op.get("layout") or 1) if layout_idx < 0 or layout_idx >= len(prs.slide_layouts): layout_idx = 1 slide = prs.slides.add_slide(prs.slide_layouts[layout_idx]) if slide.shapes.title: slide.shapes.title.text = str(op.get("title") or "") lines = op.get("bullets") if lines is None: lines = op.get("lines") if lines is None: lines = [op.get("text")] if op.get("text") is not None else [] if len(slide.placeholders) > 1: body = slide.placeholders[1].text_frame body.clear() first = True for line in lines: if first: body.text = str(line) first = False else: p = body.add_paragraph() p.text = str(line) elif op_type == "add_table": slide_index = int(op.get("slide_index") or len(prs.slides)) if slide_index < 0: slide_index = 0 while len(prs.slides) <= slide_index: prs.slides.add_slide(prs.slide_layouts[1]) slide = prs.slides[slide_index] headers = [str(h) for h in (op.get("headers") or [])] rows_raw = op.get("rows") or [] rows = self._normalize_rows(rows_raw, headers=headers if headers else None) if rows and not headers and isinstance(rows_raw[0], dict): headers = list(rows_raw[0].keys()) rows = self._normalize_rows(rows_raw, headers=headers) row_count = len(rows) + (1 if headers else 0) col_count = len(headers) if headers else (len(rows[0]) if rows else 1) left = Inches(float(op.get("left_inches") or 1.0)) top = Inches(float(op.get("top_inches") or 1.5)) width = Inches(float(op.get("width_inches") or 8.0)) height = Inches(float(op.get("height_inches") or 3.0)) table = slide.shapes.add_table(max(1, row_count), max(1, col_count), left, top, width, height).table offset = 0 if headers: for idx, value in enumerate(headers): table.cell(0, idx).text = str(value) offset = 1 for r_idx, row in enumerate(rows): for c_idx, value in enumerate(row): if c_idx < col_count: table.cell(r_idx + offset, c_idx).text = str(value) elif op_type == "replace_text": old = str(op.get("old") or "") new = str(op.get("new") or "") if not old: return ToolResult(success=False, result=None, error="replace_text requires old") for slide in prs.slides: for shape in slide.shapes: if not hasattr(shape, "text"): continue text = shape.text or "" if old in text: shape.text = text.replace(old, new) elif op_type == "replace_text_preserve_layout": old = str(op.get("old") or "") new = str(op.get("new") or "") if not old: return ToolResult(success=False, result=None, error="replace_text_preserve_layout requires old") for slide in prs.slides: for shape in slide.shapes: if hasattr(shape, "text_frame") and shape.text_frame: for paragraph in shape.text_frame.paragraphs: for run in paragraph.runs: if old in run.text: run.text = run.text.replace(old, new) if getattr(shape, "has_table", False): for row in shape.table.rows: for cell in row.cells: for paragraph in cell.text_frame.paragraphs: for run in paragraph.runs: if old in run.text: run.text = run.text.replace(old, new) else: return ToolResult(success=False, result=None, error=f"Unsupported pptx_update operation: {op_type}") out = BytesIO() prs.save(out) return ToolResult( success=True, result={"message": f"PPTX updated: {file_name}"}, file_base64=self._b64_from_bytes(out.getvalue()), file_name=file_name, file_mime="application/vnd.openxmlformats-officedocument.presentationml.presentation", ) def _file_pdf_merge(self, args: Dict[str, Any]) -> ToolResult: from pypdf import PdfReader, PdfWriter file_name = self._sanitize_file_name(args.get("file_name"), "merged.pdf", force_ext=".pdf") files = args.get("files") or [] if not isinstance(files, list) or not files: return ToolResult(success=False, result=None, error="files must be non-empty array for pdf_merge") writer = PdfWriter() page_count = 0 for item in files: if not isinstance(item, dict) or not item.get("file_base64"): return ToolResult(success=False, result=None, error="Each file entry must include file_base64") reader = PdfReader(BytesIO(self._bytes_from_b64(item["file_base64"]))) for page in reader.pages: writer.add_page(page) page_count += 1 out = BytesIO() writer.write(out) return ToolResult( success=True, result={"message": f"PDF merged: {file_name} ({page_count} pages)"}, file_base64=self._b64_from_bytes(out.getvalue()), file_name=file_name, file_mime="application/pdf", ) @staticmethod def _parse_split_pages(pages: Any) -> Optional[List[int]]: if not isinstance(pages, list) or not pages: return None parsed: List[int] = [] for p in pages: idx = int(p) if idx < 1: return None parsed.append(idx) return sorted(set(parsed)) def _file_pdf_split(self, args: Dict[str, Any]) -> ToolResult: from pypdf import PdfReader, PdfWriter src_b64 = args.get("file_base64") if not src_b64: return ToolResult(success=False, result=None, error="file_base64 is required for pdf_split") file_name = self._sanitize_file_name(args.get("file_name"), "split.zip", force_ext=".zip") reader = PdfReader(BytesIO(self._bytes_from_b64(src_b64))) total = len(reader.pages) if total == 0: return ToolResult(success=False, result=None, error="Input PDF has no pages") groups = args.get("groups") split_groups = [] if groups: if not isinstance(groups, list): return ToolResult(success=False, result=None, error="groups must be array") for idx, grp in enumerate(groups, start=1): if not isinstance(grp, dict): return ToolResult(success=False, result=None, error="Each group must be object") gname = self._sanitize_file_name(grp.get("file_name"), f"part_{idx}.pdf", force_ext=".pdf") pages = self._parse_split_pages(grp.get("pages")) if not pages: return ToolResult(success=False, result=None, error=f"Invalid pages in group {idx}") split_groups.append((gname, pages)) else: split_groups = [(f"page_{i+1}.pdf", [i + 1]) for i in range(total)] out = BytesIO() with ZipFile(out, mode="w", compression=ZIP_DEFLATED) as zf: for gname, pages in split_groups: writer = PdfWriter() for p in pages: if p > total: return ToolResult(success=False, result=None, error=f"Page out of range: {p} > {total}") writer.add_page(reader.pages[p - 1]) part = BytesIO() writer.write(part) zf.writestr(gname, part.getvalue()) return ToolResult( success=True, result={"message": f"PDF split into {len(split_groups)} file(s): {file_name}"}, file_base64=self._b64_from_bytes(out.getvalue()), file_name=file_name, file_mime="application/zip", ) def _file_pdf_fill(self, args: Dict[str, Any]) -> ToolResult: from pypdf import PdfReader, PdfWriter src_b64 = args.get("file_base64") fields = args.get("fields") or {} if not src_b64: return ToolResult(success=False, result=None, error="file_base64 is required for pdf_fill") if not isinstance(fields, dict) or not fields: return ToolResult(success=False, result=None, error="fields must be a non-empty object") file_name = self._sanitize_file_name(args.get("file_name"), "filled.pdf", force_ext=".pdf") reader = PdfReader(BytesIO(self._bytes_from_b64(src_b64))) writer = PdfWriter() writer.append(reader) filled = True try: for page in writer.pages: writer.update_page_form_field_values(page, fields) if hasattr(writer, "set_need_appearances_writer"): writer.set_need_appearances_writer(True) except Exception: filled = False out = BytesIO() writer.write(out) msg = f"PDF form filled: {file_name}" if filled else f"PDF has no fillable form fields, returned unchanged: {file_name}" return ToolResult( success=True, result={"message": msg}, file_base64=self._b64_from_bytes(out.getvalue()), file_name=file_name, file_mime="application/pdf", ) def _file_pdf_update(self, args: Dict[str, Any]) -> ToolResult: from pypdf import PdfReader, PdfWriter src_b64 = args.get("file_base64") operations = args.get("operations") or [] if not src_b64: return ToolResult(success=False, result=None, error="file_base64 is required for pdf_update") if not isinstance(operations, list) or not operations: return ToolResult(success=False, result=None, error="operations must be non-empty array") file_name = self._sanitize_file_name(args.get("file_name"), "updated.pdf", force_ext=".pdf") reader = PdfReader(BytesIO(self._bytes_from_b64(src_b64))) pages = [reader.pages[i] for i in range(len(reader.pages))] total = len(pages) if total == 0: return ToolResult(success=False, result=None, error="Input PDF has no pages") def parse_pages_list(raw: Any, allow_empty: bool = False) -> Optional[List[int]]: if raw is None: return [] if allow_empty else None if not isinstance(raw, list) or (not raw and not allow_empty): return None out: List[int] = [] for val in raw: try: idx = int(val) except Exception: return None if idx < 1 or idx > len(pages): return None out.append(idx) return out for op in operations: if not isinstance(op, dict): return ToolResult(success=False, result=None, error="Each operation must be object") op_type = str(op.get("type") or "").strip().lower() if op_type == "rotate_pages": angle = int(op.get("angle") or 90) if angle not in {90, 180, 270}: return ToolResult(success=False, result=None, error="rotate_pages angle must be 90|180|270") target = parse_pages_list(op.get("pages"), allow_empty=True) if target is None: return ToolResult(success=False, result=None, error="Invalid pages for rotate_pages") targets = target or list(range(1, len(pages) + 1)) for p in targets: page = pages[p - 1] try: pages[p - 1] = page.rotate(angle) except Exception: if hasattr(page, "rotate_clockwise"): page.rotate_clockwise(angle) pages[p - 1] = page else: return ToolResult(success=False, result=None, error="PDF rotation not supported by library") elif op_type == "remove_pages": target = parse_pages_list(op.get("pages")) if not target: return ToolResult(success=False, result=None, error="remove_pages requires pages") drop = set(target) pages = [p for idx, p in enumerate(pages, start=1) if idx not in drop] if not pages: return ToolResult(success=False, result=None, error="remove_pages removed all pages") elif op_type in {"reorder_pages", "extract_pages"}: target = parse_pages_list(op.get("pages")) if not target: return ToolResult(success=False, result=None, error=f"{op_type} requires pages") pages = [pages[i - 1] for i in target] elif op_type == "set_metadata": # Applied later on writer to avoid page object recreation. continue else: return ToolResult(success=False, result=None, error=f"Unsupported pdf_update operation: {op_type}") writer = PdfWriter() for page in pages: writer.add_page(page) for op in operations: if isinstance(op, dict) and str(op.get("type") or "").strip().lower() == "set_metadata": meta = op.get("metadata") if isinstance(meta, dict) and meta: normalized = {k if str(k).startswith("/") else f"/{k}": str(v) for k, v in meta.items()} writer.add_metadata(normalized) out = BytesIO() writer.write(out) return ToolResult( success=True, result={"message": f"PDF updated: {file_name} ({len(pages)} pages)"}, file_base64=self._b64_from_bytes(out.getvalue()), file_name=file_name, file_mime="application/pdf", ) def _file_djvu_to_pdf(self, args: Dict[str, Any]) -> ToolResult: src_b64 = args.get("file_base64") if not src_b64: return ToolResult(success=False, result=None, error="file_base64 is required for djvu_to_pdf") file_name = self._sanitize_file_name(args.get("file_name"), "converted.pdf", force_ext=".pdf") timeout_sec = max(5, min(int(args.get("timeout_sec") or 60), 300)) with tempfile.TemporaryDirectory(prefix="djvu2pdf_") as tmpdir: src = os.path.join(tmpdir, "input.djvu") out_pdf = os.path.join(tmpdir, "output.pdf") with open(src, "wb") as f: f.write(self._bytes_from_b64(src_b64)) try: proc = subprocess.run( ["ddjvu", "-format=pdf", src, out_pdf], capture_output=True, text=True, timeout=timeout_sec, check=False, ) except FileNotFoundError: return ToolResult(success=False, result=None, error="ddjvu not found in runtime image") except subprocess.TimeoutExpired: return ToolResult(success=False, result=None, error=f"DJVU conversion timed out ({timeout_sec}s)") if proc.returncode != 0 or not os.path.exists(out_pdf): stderr = (proc.stderr or "").strip() return ToolResult(success=False, result=None, error=f"ddjvu failed: {stderr or 'unknown error'}") data = open(out_pdf, "rb").read() if not data: return ToolResult(success=False, result=None, error="ddjvu produced empty PDF") return ToolResult( success=True, result={"message": f"DJVU converted to PDF: {file_name}"}, file_base64=self._b64_from_bytes(data), file_name=file_name, file_mime="application/pdf", ) def _file_djvu_extract_text(self, args: Dict[str, Any]) -> ToolResult: src_b64 = args.get("file_base64") if not src_b64: return ToolResult(success=False, result=None, error="file_base64 is required for djvu_extract_text") file_name = self._sanitize_file_name(args.get("file_name"), "extracted.txt", force_ext=".txt") timeout_sec = max(5, min(int(args.get("timeout_sec") or 60), 300)) with tempfile.TemporaryDirectory(prefix="djvutxt_") as tmpdir: src = os.path.join(tmpdir, "input.djvu") with open(src, "wb") as f: f.write(self._bytes_from_b64(src_b64)) try: proc = subprocess.run( ["djvutxt", src], capture_output=True, text=True, timeout=timeout_sec, check=False, ) except FileNotFoundError: return ToolResult(success=False, result=None, error="djvutxt not found in runtime image") except subprocess.TimeoutExpired: return ToolResult(success=False, result=None, error=f"DJVU text extraction timed out ({timeout_sec}s)") if proc.returncode != 0: stderr = (proc.stderr or "").strip() return ToolResult(success=False, result=None, error=f"djvutxt failed: {stderr or 'unknown error'}") text = proc.stdout or "" msg = f"DJVU text extracted: {file_name}" if not text.strip(): msg = f"DJVU has no extractable text layer, returned empty text file: {file_name}" payload = text.encode("utf-8") return ToolResult( success=True, result={"message": msg}, file_base64=self._b64_from_bytes(payload), file_name=file_name, file_mime="text/plain", ) async def _memory_search(self, args: Dict, agent_id: str = None, chat_id: str = None, user_id: str = None) -> ToolResult: """Search in Qdrant vector memory using Router's memory_retrieval - PRIORITY 1""" query = args.get("query") try: # Use Router's memory_retrieval pipeline directly (has Qdrant connection) from memory_retrieval import memory_retrieval if memory_retrieval and memory_retrieval.qdrant_client: results = await memory_retrieval.search_memories( query=query, agent_id=agent_id or "helion", chat_id=chat_id, user_id=user_id, limit=5 ) if results: formatted = [] for r in results: text = r.get("text", "") score = r.get("score", 0) mem_type = r.get("type", "memory") if text: formatted.append(f"• [{mem_type}] {text[:200]}... (релевантність: {score:.2f})") if formatted: return ToolResult(success=True, result=f"🧠 Знайдено в пам'яті:\n" + "\n".join(formatted)) return ToolResult(success=True, result="🧠 В моїй пам'яті немає інформації про це.") else: return ToolResult(success=True, result="🧠 Пам'ять недоступна, спробую web_search.") except Exception as e: logger.warning(f"Memory search error: {e}") return ToolResult(success=True, result="🧠 Не вдалося перевірити пам'ять. Спробую інші джерела.") async def _web_search(self, args: Dict) -> ToolResult: """Execute web search - PRIORITY 2 (use after memory_search)""" query = args.get("query") max_results = args.get("max_results", 5) if not query: return ToolResult(success=False, result=None, error="query is required") try: resp = await self.http_client.post( f"{self.swapper_url}/web/search", json={"query": query, "max_results": max_results} ) if resp.status_code == 200: data = resp.json() results = data.get("results", []) or [] query_terms = {t for t in str(query).lower().replace("/", " ").replace("-", " ").split() if len(t) > 2} trusted_domains = { "wikipedia.org", "wikidata.org", "europa.eu", "fao.org", "who.int", "worldbank.org", "oecd.org", "un.org", "gov.ua", "rada.gov.ua", "kmu.gov.ua", "minagro.gov.ua", "agroportal.ua", "latifundist.com", } low_signal_domains = { "pinterest.com", "tiktok.com", "instagram.com", "facebook.com", "youtube.com", "yandex.", "vk.com", } def _extract_domain(url: str) -> str: if not url: return "" u = url.lower().strip() u = u.replace("https://", "").replace("http://", "") u = u.split("/")[0] if u.startswith("www."): u = u[4:] return u def _overlap_score(title: str, snippet: str, url: str) -> int: text = " ".join([title or "", snippet or "", url or ""]).lower() score = 0 for t in query_terms: if t in text: score += 2 return score ranked: List[Any] = [] for r in results: title = str(r.get("title", "") or "") snippet = str(r.get("snippet", "") or "") url = str(r.get("url", "") or "") domain = _extract_domain(url) score = _overlap_score(title, snippet, url) if any(x in domain for x in low_signal_domains): score -= 2 if any(domain == d or domain.endswith("." + d) for d in trusted_domains): score += 2 if not snippet: score -= 1 if len(title.strip()) < 5: score -= 1 ranked.append((score, r)) ranked.sort(key=lambda x: x[0], reverse=True) selected = [item for _, item in ranked[:max_results]] logger.info(f"🔎 web_search rerank: raw={len(results)} ranked={len(selected)} query='{query[:120]}'") formatted = [] for r in selected: formatted.append( f"- {r.get('title', 'No title')}\n {r.get('snippet', '')}\n URL: {r.get('url', '')}" ) return ToolResult(success=True, result="\n".join(formatted) if formatted else "Нічого не знайдено") else: return ToolResult(success=False, result=None, error=f"Search failed: {resp.status_code}") except Exception as e: return ToolResult(success=False, result=None, error=str(e)) async def _web_extract(self, args: Dict) -> ToolResult: """Extract content from URL""" url = args.get("url") try: resp = await self.http_client.post( f"{self.swapper_url}/web/extract", json={"url": url} ) if resp.status_code == 200: data = resp.json() content = data.get("content", "") # Truncate if too long if len(content) > 4000: content = content[:4000] + "\n... (текст обрізано)" return ToolResult(success=True, result=content) else: return ToolResult(success=False, result=None, error=f"Extract failed: {resp.status_code}") except Exception as e: return ToolResult(success=False, result=None, error=str(e)) async def _unload_ollama_models(self): """Unload all Ollama models to free VRAM for heavy operations like FLUX""" ollama_url = os.getenv("OLLAMA_BASE_URL", "http://172.18.0.1:11434") models_to_unload = ["qwen3:8b", "qwen3-vl:8b"] for model in models_to_unload: try: await self.http_client.post( f"{ollama_url}/api/generate", json={"model": model, "keep_alive": 0}, timeout=5.0 ) logger.info(f"🧹 Unloaded Ollama model: {model}") except Exception as e: logger.debug(f"Could not unload {model}: {e}") # Give GPU time to release memory import asyncio await asyncio.sleep(1) async def _unload_flux(self): """Unload FLUX model after image generation to free VRAM""" try: # Try to unload flux-klein-4b model await self.http_client.post( f"{self.swapper_url}/image/models/flux-klein-4b/unload", timeout=10.0 ) logger.info("🧹 Unloaded FLUX model from Swapper") except Exception as e: logger.debug(f"Could not unload FLUX: {e}") async def _image_generate(self, args: Dict) -> ToolResult: """Backward-compatible image generation with fallback chain.""" if not args.get("prompt"): return ToolResult(success=False, result=None, error="prompt is required") prepared = dict(args) prepared.setdefault("negative_prompt", "blurry, low quality, watermark") prepared.setdefault("steps", 28) prepared.setdefault("timeout_s", 180) providers = [ ("comfy", self._comfy_generate_image), ("swapper", self._swapper_generate_image), ("image_gen_service", self._image_gen_service_generate), ] errors: List[str] = [] for name, handler in providers: try: result = await handler(dict(prepared)) except Exception as e: msg = f"{name}: {str(e)[:200]}" logger.warning("image_generate provider exception: %s", msg) errors.append(msg) continue if result.success: return result errors.append(f"{name}: {result.error or 'unknown error'}") return ToolResult( success=False, result=None, error="All image providers failed: " + " | ".join(errors), ) async def _swapper_generate_image(self, args: Dict) -> ToolResult: """Generate image via swapper-service (local fallback).""" prompt = args.get("prompt") if not prompt: return ToolResult(success=False, result=None, error="prompt is required") payload = { "model": args.get("model", "flux-klein-4b"), "prompt": prompt, "negative_prompt": args.get("negative_prompt", ""), "num_inference_steps": int(args.get("steps", args.get("num_inference_steps", 28))), "guidance_scale": float(args.get("guidance_scale", 4.0)), "width": int(args.get("width", 1024)), "height": int(args.get("height", 1024)), } timeout_s = max(30, int(args.get("timeout_s", 180))) try: resp = await self.http_client.post( f"{self.swapper_url}/image/generate", json=payload, timeout=timeout_s, ) except Exception as e: return ToolResult(success=False, result=None, error=f"Swapper request failed: {str(e)[:200]}") if resp.status_code >= 400: detail = (resp.text or "").strip()[:220] return ToolResult(success=False, result=None, error=f"Swapper error {resp.status_code}: {detail}") data = resp.json() image_base64 = data.get("image_base64") if image_base64: return ToolResult( success=True, result="✅ Зображення згенеровано через swapper-service", image_base64=image_base64, ) if data.get("success"): return ToolResult(success=True, result=json.dumps(data, ensure_ascii=False)) return ToolResult(success=False, result=None, error="Swapper returned no image payload") async def _image_gen_service_generate(self, args: Dict) -> ToolResult: """Generate image via image-gen-service as final fallback.""" prompt = args.get("prompt") if not prompt: return ToolResult(success=False, result=None, error="prompt is required") payload = { "prompt": prompt, "negative_prompt": args.get("negative_prompt"), "width": int(args.get("width", 1024)), "height": int(args.get("height", 1024)), "num_inference_steps": int(args.get("steps", args.get("num_inference_steps", 25))), "guidance_scale": float(args.get("guidance_scale", 3.5)), } seed = args.get("seed") if seed is not None: payload["seed"] = int(seed) timeout_s = max(30, int(args.get("timeout_s", 240))) try: resp = await self.http_client.post( f"{self.image_gen_service_url}/generate", json=payload, timeout=timeout_s, ) except Exception as e: return ToolResult(success=False, result=None, error=f"Image-gen request failed: {str(e)[:200]}") if resp.status_code >= 400: detail = (resp.text or "").strip()[:220] return ToolResult(success=False, result=None, error=f"Image-gen error {resp.status_code}: {detail}") data = resp.json() image_base64 = data.get("image_base64") if image_base64: return ToolResult( success=True, result="✅ Зображення згенеровано через image-gen-service", image_base64=image_base64, ) return ToolResult(success=False, result=None, error="Image-gen service returned no image payload") async def _poll_comfy_job(self, job_id: str, timeout_s: int = 180) -> Dict[str, Any]: """Poll Comfy Agent job status until terminal state or timeout.""" loop = asyncio.get_running_loop() deadline = loop.time() + max(10, timeout_s) delay = 1.0 last: Dict[str, Any] = {} while loop.time() < deadline: resp = await self.http_client.get(f"{self.comfy_agent_url}/status/{job_id}", timeout=30.0) if resp.status_code != 200: raise RuntimeError(f"Comfy status failed: {resp.status_code}") data = resp.json() last = data status = (data.get("status") or "").lower() if status in {"succeeded", "finished"}: return data if status in {"failed", "canceled", "cancelled", "expired"}: return data await asyncio.sleep(delay) delay = min(delay * 1.5, 5.0) raise TimeoutError(f"Comfy job timeout after {timeout_s}s (job_id={job_id})") async def _comfy_generate_image(self, args: Dict) -> ToolResult: """Generate image via Comfy Agent on NODE3 and return URL when ready.""" prompt = args.get("prompt") if not prompt: return ToolResult(success=False, result=None, error="prompt is required") payload = { "prompt": prompt, "negative_prompt": args.get("negative_prompt", "blurry, low quality, watermark"), "width": int(args.get("width", 512)), "height": int(args.get("height", 512)), "steps": int(args.get("steps", 28)), } if args.get("seed") is not None: payload["seed"] = int(args["seed"]) timeout_s = int(args.get("timeout_s", 180)) idem_key = args.get("idempotency_key") or f"router-{uuid.uuid4().hex}" try: resp = await self.http_client.post( f"{self.comfy_agent_url}/generate/image", json=payload, headers={"Idempotency-Key": idem_key}, timeout=30.0, ) if resp.status_code != 200: return ToolResult(success=False, result=None, error=f"Comfy image request failed: {resp.status_code}") created = resp.json() job_id = created.get("job_id") if not job_id: return ToolResult(success=False, result=None, error="Comfy image request did not return job_id") final = await self._poll_comfy_job(job_id, timeout_s=timeout_s) status = (final.get("status") or "").lower() if status in {"succeeded", "finished"}: result_url = final.get("result_url") if result_url: return ToolResult(success=True, result=f"✅ Зображення згенеровано: {result_url}") return ToolResult(success=True, result=f"✅ Зображення згенеровано. job_id={job_id}") return ToolResult(success=False, result=None, error=final.get("error") or f"Comfy image failed (status={status})") except Exception as e: return ToolResult(success=False, result=None, error=str(e)) async def _comfy_generate_video(self, args: Dict) -> ToolResult: """Generate video via Comfy Agent on NODE3 and return URL when ready.""" prompt = args.get("prompt") if not prompt: return ToolResult(success=False, result=None, error="prompt is required") payload = { "prompt": prompt, "seconds": int(args.get("seconds", 4)), "fps": int(args.get("fps", 24)), "steps": int(args.get("steps", 30)), } if args.get("seed") is not None: payload["seed"] = int(args["seed"]) timeout_s = int(args.get("timeout_s", 300)) idem_key = args.get("idempotency_key") or f"router-{uuid.uuid4().hex}" try: resp = await self.http_client.post( f"{self.comfy_agent_url}/generate/video", json=payload, headers={"Idempotency-Key": idem_key}, timeout=30.0, ) if resp.status_code != 200: return ToolResult(success=False, result=None, error=f"Comfy video request failed: {resp.status_code}") created = resp.json() job_id = created.get("job_id") if not job_id: return ToolResult(success=False, result=None, error="Comfy video request did not return job_id") final = await self._poll_comfy_job(job_id, timeout_s=timeout_s) status = (final.get("status") or "").lower() if status in {"succeeded", "finished"}: result_url = final.get("result_url") if result_url: return ToolResult(success=True, result=f"✅ Відео згенеровано: {result_url}") return ToolResult(success=True, result=f"✅ Відео згенеровано. job_id={job_id}") return ToolResult(success=False, result=None, error=final.get("error") or f"Comfy video failed (status={status})") except Exception as e: return ToolResult(success=False, result=None, error=str(e)) async def _graph_query(self, args: Dict, agent_id: str = None) -> ToolResult: """Query knowledge graph""" query = args.get("query") entity_type = args.get("entity_type") # Simple natural language to Cypher conversion cypher = f""" MATCH (n) WHERE toLower(n.name) CONTAINS toLower('{query}') OR toLower(toString(n)) CONTAINS toLower('{query}') RETURN labels(n)[0] as type, n.name as name, n.node_id as id LIMIT 10 """ if entity_type: cypher = f""" MATCH (n:{entity_type}) WHERE toLower(n.name) CONTAINS toLower('{query}') RETURN n.name as name, n.node_id as id LIMIT 10 """ try: # Execute via Router's graph endpoint resp = await self.http_client.post( "http://localhost:8000/v1/graph/query", json={"query": cypher} ) if resp.status_code == 200: data = resp.json() return ToolResult(success=True, result=json.dumps(data.get("results", []), ensure_ascii=False)) else: return ToolResult(success=False, result=None, error=f"Graph query failed: {resp.status_code}") except Exception as e: return ToolResult(success=False, result=None, error=str(e)) async def _remember_fact(self, args: Dict, agent_id: str = None, chat_id: str = None, user_id: str = None) -> ToolResult: """Store a fact in memory with strict args validation.""" if not isinstance(args, dict) or not args: logger.warning("⚠️ remember_fact blocked: empty args") return ToolResult(success=False, result=None, error="invalid_tool_args: remember_fact requires {fact: }.") fact_raw = args.get("fact") if fact_raw is None: fact_raw = args.get("text") if not isinstance(fact_raw, str): logger.warning("⚠️ remember_fact blocked: fact/text must be string") return ToolResult(success=False, result=None, error="invalid_tool_args: fact/text must be string.") fact = fact_raw.strip() if not fact: logger.warning("⚠️ remember_fact blocked: empty fact/text") return ToolResult(success=False, result=None, error="invalid_tool_args: fact/text must be non-empty.") if len(fact) > 2000: logger.warning("⚠️ remember_fact blocked: fact too long (%s)", len(fact)) return ToolResult(success=False, result=None, error="invalid_tool_args: fact/text is too long (max 2000 chars).") category = str(args.get("category") or "general").strip() or "general" runtime_user_id = (str(user_id or "").strip() or str(args.get("user_id") or "").strip() or str(args.get("about") or "").strip()) if not runtime_user_id: logger.warning("⚠️ remember_fact blocked: missing runtime user_id") return ToolResult(success=False, result=None, error="invalid_tool_args: missing runtime user_id for memory write.") fact_hash = hashlib.sha1(fact.encode("utf-8")).hexdigest()[:12] fact_key = f"{category}_{fact_hash}" try: resp = await self.http_client.post( "http://memory-service:8000/facts/upsert", json={ "user_id": runtime_user_id, "fact_key": fact_key, "fact_value": fact, "agent_id": agent_id, "fact_value_json": { "text": fact, "category": category, "about": runtime_user_id, "agent_id": agent_id, "chat_id": chat_id, "source": "remember_fact_tool", }, }, ) if resp.status_code in [200, 201]: return ToolResult(success=True, result=f"✅ Запам'ятовано факт ({category})") return ToolResult(success=False, result=None, error=f"memory_store_failed:{resp.status_code}:{resp.text[:160]}") except Exception as e: return ToolResult(success=False, result=None, error=str(e)) async def _presentation_create(self, args: Dict) -> ToolResult: """Create a presentation via Presentation Renderer""" title = args.get("title", "Презентація") slides = args.get("slides", []) brand_id = args.get("brand_id", "energyunion") theme_version = args.get("theme_version", "v1.0.0") language = args.get("language", "uk") # Build SlideSpec slidespec = { "meta": { "title": title, "brand_id": brand_id, "theme_version": theme_version, "language": language }, "slides": [] } # Add title slide slidespec["slides"].append({ "type": "title", "title": title }) # Add content slides for slide in slides: slide_obj = { "type": "content", "title": slide.get("title", ""), "body": slide.get("content", "") } slidespec["slides"].append(slide_obj) try: renderer_url = os.getenv("PRESENTATION_RENDERER_URL", "http://presentation-renderer:9600") resp = await self.http_client.post( f"{renderer_url}/present/render", json=slidespec, timeout=120.0 ) if resp.status_code == 200: data = resp.json() job_id = data.get("job_id") artifact_id = data.get("artifact_id") return ToolResult( success=True, result=f"📊 Презентацію створено!\n\n🆔 Job ID: `{job_id}`\n📦 Artifact ID: `{artifact_id}`\n\nЩоб перевірити статус: використай presentation_status\nЩоб завантажити: використай presentation_download" ) else: error_text = resp.text[:200] if resp.text else "Unknown error" return ToolResult(success=False, result=None, error=f"Render failed ({resp.status_code}): {error_text}") except Exception as e: return ToolResult(success=False, result=None, error=str(e)) async def _presentation_status(self, args: Dict) -> ToolResult: """Check presentation job status""" job_id = args.get("job_id") try: registry_url = os.getenv("ARTIFACT_REGISTRY_URL", "http://artifact-registry:9700") resp = await self.http_client.get( f"{registry_url}/jobs/{job_id}", timeout=10.0 ) if resp.status_code == 200: data = resp.json() status = data.get("status", "unknown") artifact_id = data.get("artifact_id") error = data.get("error_text", "") status_emoji = {"queued": "⏳", "running": "🔄", "done": "✅", "failed": "❌"}.get(status, "❓") result = f"{status_emoji} Статус: **{status}**\n" if artifact_id: result += f"📦 Artifact ID: `{artifact_id}`\n" if status == "done": result += "\n✅ Презентація готова! Використай presentation_download щоб отримати файл." if status == "failed" and error: result += f"\n❌ Помилка: {error[:200]}" return ToolResult(success=True, result=result) elif resp.status_code == 404: return ToolResult(success=False, result=None, error="Job not found") else: return ToolResult(success=False, result=None, error=f"Status check failed: {resp.status_code}") except Exception as e: return ToolResult(success=False, result=None, error=str(e)) async def _presentation_download(self, args: Dict) -> ToolResult: """Get download link for presentation""" artifact_id = args.get("artifact_id") file_format = args.get("format", "pptx") try: registry_url = os.getenv("ARTIFACT_REGISTRY_URL", "http://artifact-registry:9700") resp = await self.http_client.get( f"{registry_url}/artifacts/{artifact_id}/download?format={file_format}", timeout=10.0, follow_redirects=False ) if resp.status_code in [200, 302, 307]: # Check for signed URL in response or Location header if resp.status_code in [302, 307]: download_url = resp.headers.get("Location") else: data = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {} download_url = data.get("download_url") or data.get("url") if download_url: return ToolResult( success=True, result=f"📥 **Посилання для завантаження ({file_format.upper()}):**\n\n{download_url}\n\n⏰ Посилання дійсне 30 хвилин." ) else: # Direct binary response - artifact available return ToolResult( success=True, result=f"✅ Файл {file_format.upper()} готовий! Завантажити можна через: {registry_url}/artifacts/{artifact_id}/download?format={file_format}" ) elif resp.status_code == 404: return ToolResult(success=False, result=None, error=f"Формат {file_format.upper()} ще не готовий. Спробуй пізніше.") else: return ToolResult(success=False, result=None, error=f"Download failed: {resp.status_code}") except Exception as e: return ToolResult(success=False, result=None, error=str(e)) async def _crawl4ai_scrape(self, args: Dict) -> ToolResult: """Deep scrape a web page using Crawl4AI - PRIORITY 5""" url = args.get("url") extract_links = args.get("extract_links", True) extract_images = args.get("extract_images", False) if not url: return ToolResult(success=False, result=None, error="URL is required") try: crawl4ai_url = os.getenv("CRAWL4AI_URL", "http://dagi-crawl4ai-node1:11235") payload = { "urls": [url], "priority": 5, "session_id": f"agent_scrape_{hash(url) % 10000}" } resp = await self.http_client.post( f"{crawl4ai_url}/crawl", json=payload, timeout=60.0 ) if resp.status_code == 200: data = resp.json() results = data.get("results", []) if isinstance(data, dict) else [] if not results and isinstance(data, dict): results = [data] if results: result = results[0] if isinstance(results, list) else results markdown = result.get("markdown", "") or result.get("cleaned_html", "") or result.get("text", "") title = result.get("title", url) if len(markdown) > 3000: markdown = markdown[:3000] + "... (скорочено)" response_parts = [f"**{title}**", "", markdown] if extract_links: links = result.get("links", []) if links: response_parts.append("") response_parts.append("**Посилання:**") for link in links[:10]: if isinstance(link, dict): link_url = link.get("href", "") else: link_url = str(link) if link_url: response_parts.append(f"- {link_url}") return ToolResult(success=True, result="\n".join(response_parts)) else: return ToolResult(success=False, result=None, error="No content extracted") else: return ToolResult(success=False, result=None, error=f"Crawl failed: {resp.status_code}") except Exception as e: logger.error(f"Crawl4AI scrape failed: {e}") return ToolResult(success=False, result=None, error=str(e)) async def _tts_speak(self, args: Dict) -> ToolResult: """Convert text to speech using Swapper TTS - PRIORITY 6""" text = args.get("text") language = args.get("language", "uk") if not text: return ToolResult(success=False, result=None, error="Text is required") try: if len(text) > 1000: text = text[:1000] resp = await self.http_client.post( f"{self.swapper_url}/tts", json={"text": text, "language": language}, timeout=60.0 ) if resp.status_code == 200: data = resp.json() audio_url = data.get("audio_url") or data.get("url") if audio_url: return ToolResult(success=True, result=f"Аудіо: {audio_url}") else: return ToolResult(success=True, result="TTS completed") else: return ToolResult(success=False, result=None, error=f"TTS failed: {resp.status_code}") except Exception as e: logger.error(f"TTS failed: {e}") return ToolResult(success=False, result=None, error=str(e)) async def _market_data(self, args: Dict) -> ToolResult: """Query real-time market data. Supports 23 symbols incl. XAU/XAG via Kraken.""" symbol = str(args.get("symbol", "BTCUSDT")).upper() query_type = str(args.get("query_type", "all")).lower() md_url = os.getenv("MARKET_DATA_URL", "http://dagi-market-data-node1:8891") consumer_url = os.getenv("SENPAI_CONSUMER_URL", "http://dagi-senpai-md-consumer-node1:8892") binance_monitor_url = os.getenv("BINANCE_MONITOR_URL", "http://dagi-binance-bot-monitor-node1:8893") # Symbols served via binance-bot-monitor CCXT (not available in market-data-service WS) CCXT_SYMBOLS = { 'XAUUSDT', 'XAGUSDT', # Gold/Silver via Kraken 'BNBUSDT', 'SOLUSDT', 'XRPUSDT', 'ADAUSDT', 'DOGEUSDT', 'AVAXUSDT', 'DOTUSDT', 'LINKUSDT', 'POLUSDT', 'SHIBUSDT', 'TRXUSDT', 'UNIUSDT', 'LTCUSDT', 'ATOMUSDT', 'NEARUSDT', 'ICPUSDT', 'FILUSDT', 'APTUSDT', 'PAXGUSDT', } results: Dict[str, Any] = {} try: async with httpx.AsyncClient(timeout=10.0) as client: # multi mode: return all 23 symbols at once if query_type == "multi": try: resp = await client.get(f"{binance_monitor_url}/prices", timeout=12.0) if resp.status_code == 200: return ToolResult(success=True, result=resp.text) except Exception as e: return ToolResult(success=False, result=None, error=f"multi prices fetch error: {e}") # For XAU/XAG/PAXG and extended symbols — use binance-bot-monitor CCXT if symbol in CCXT_SYMBOLS or query_type == "price": try: resp = await client.get(f"{binance_monitor_url}/price", params={"symbol": symbol}, timeout=10.0) if resp.status_code == 200: data = resp.json() results["price"] = { "symbol": symbol, "last_price": data.get("price"), "bid": data.get("bid"), "ask": data.get("ask"), "volume_24h": data.get("volume_24h"), "price_change_pct_24h": data.get("price_change_pct_24h"), "high_24h": data.get("high_24h"), "low_24h": data.get("low_24h"), "exchange": data.get("exchange", "binance"), "note": data.get("note"), "error": data.get("error"), } else: results["price_error"] = f"binance-monitor status={resp.status_code}" except Exception as e: results["price_error"] = str(e) # For BTC/ETH (WS primary) also get features from senpai-consumer if symbol in ('BTCUSDT', 'ETHUSDT') and query_type in ("features", "all"): # WS price from market-data-service if query_type == "all" and "price" not in results: try: resp = await client.get(f"{md_url}/latest", params={"symbol": symbol}) if resp.status_code == 200: data = resp.json() trade = data.get("latest_trade", {}) or {} quote = data.get("latest_quote", {}) or {} bid = quote.get("bid") ask = quote.get("ask") spread = None if isinstance(bid, (int, float)) and isinstance(ask, (int, float)): spread = round(ask - bid, 6) results["price"] = { "symbol": symbol, "last_price": trade.get("price"), "bid": bid, "ask": ask, "spread": spread, "provider": trade.get("provider"), "timestamp": trade.get("ts_recv"), } except Exception as e: results["price_ws_error"] = str(e) # Features from senpai-consumer try: resp = await client.get(f"{consumer_url}/features/latest", params={"symbol": symbol}) if resp.status_code == 200: data = resp.json() feats = data.get("features", {}) or {} results["features"] = { "symbol": symbol, "mid_price": feats.get("mid"), "spread_bps": round(float(feats.get("spread_bps", 0) or 0), 2), "vwap_10s": round(float(feats.get("trade_vwap_10s", 0) or 0), 2), "vwap_60s": round(float(feats.get("trade_vwap_60s", 0) or 0), 2), "trade_count_10s": int(feats.get("trade_count_10s", 0) or 0), "return_10s_pct": round(float(feats.get("return_10s", 0) or 0) * 100, 4), "realized_vol_60s_pct": round(float(feats.get("realized_vol_60s", 0) or 0) * 100, 6), } except Exception as e: results["features_error"] = str(e) elif symbol not in CCXT_SYMBOLS and "price" not in results: # Fallback for any symbol: try market-data-service WS try: resp = await client.get(f"{md_url}/latest", params={"symbol": symbol}) if resp.status_code == 200: data = resp.json() trade = data.get("latest_trade", {}) or {} quote = data.get("latest_quote", {}) or {} bid = quote.get("bid") ask = quote.get("ask") results["price"] = { "symbol": symbol, "last_price": trade.get("price"), "bid": bid, "ask": ask, "provider": trade.get("provider"), "timestamp": trade.get("ts_recv"), } else: results["price_error"] = f"market-data status={resp.status_code}" except Exception as e: results["price_error"] = str(e) if not results: return ToolResult(success=False, result=None, error=f"No market data for {symbol}") return ToolResult(success=True, result=json.dumps(results, ensure_ascii=False)) except Exception as e: logger.error(f"Market data tool error: {e}") return ToolResult(success=False, result=None, error=str(e)) async def _binance_bots_top(self, args: Dict) -> ToolResult: """Get top Binance Marketplace bots via binance-bot-monitor service.""" grid_type = str(args.get("grid_type", "SPOT")).upper() limit = int(args.get("limit", 10)) binance_monitor_url = os.getenv("BINANCE_MONITOR_URL", "http://dagi-binance-bot-monitor-node1:8893") try: async with httpx.AsyncClient(timeout=10.0) as client: resp = await client.get(f"{binance_monitor_url}/top-bots", params={"grid_type": grid_type, "limit": limit}) if resp.status_code == 200: data = resp.json() return ToolResult(success=True, result=json.dumps(data, ensure_ascii=False)) else: return ToolResult(success=False, result=None, error=f"binance-monitor status={resp.status_code}") except Exception as e: logger.error(f"binance_bots_top failed: {e}") return ToolResult(success=False, result=None, error=str(e)) async def _binance_account_bots(self, args: Dict) -> ToolResult: """Get own Binance sub-account bots and balances via binance-bot-monitor service.""" force_refresh = bool(args.get("force_refresh", False)) binance_monitor_url = os.getenv("BINANCE_MONITOR_URL", "http://dagi-binance-bot-monitor-node1:8893") try: async with httpx.AsyncClient(timeout=12.0) as client: resp = await client.get(f"{binance_monitor_url}/account-bots", params={"force_refresh": str(force_refresh).lower()}) if resp.status_code == 200: data = resp.json() return ToolResult(success=True, result=json.dumps(data, ensure_ascii=False)) else: return ToolResult(success=False, result=None, error=f"binance-monitor status={resp.status_code}") except Exception as e: logger.error(f"binance_account_bots failed: {e}") return ToolResult(success=False, result=None, error=str(e)) async def _oneok_http_call(self, base_url: str, path: str, payload: Dict[str, Any], method: str = "POST") -> ToolResult: url = f"{base_url}{path}" try: method_up = method.upper() headers = {} if self.oneok_adapter_api_key: headers["Authorization"] = f"Bearer {self.oneok_adapter_api_key}" if method_up == "GET": resp = await self.http_client.get(url, params=payload, headers=headers, timeout=30.0) elif method_up == "PATCH": resp = await self.http_client.patch(url, json=payload, headers=headers, timeout=30.0) else: resp = await self.http_client.post(url, json=payload, headers=headers, timeout=30.0) if resp.status_code >= 400: body = (resp.text or "")[:500] return ToolResult(success=False, result=None, error=f"{url} -> HTTP {resp.status_code}: {body}") try: data = resp.json() except Exception: data = {"text": (resp.text or "")[:1000]} return ToolResult(success=True, result=data) except Exception as e: logger.error(f"1OK adapter call failed url={url}: {e}") return ToolResult(success=False, result=None, error=f"{url} unavailable: {e}") async def _crm_search_client(self, args: Dict) -> ToolResult: query = (args or {}).get("query") if not query: return ToolResult(success=False, result=None, error="query is required") return await self._oneok_http_call(self.oneok_crm_url, "/crm/search_client", {"query": query}, method="GET") async def _crm_upsert_client(self, args: Dict) -> ToolResult: payload = (args or {}).get("client_payload") if not isinstance(payload, dict): return ToolResult(success=False, result=None, error="client_payload is required") return await self._oneok_http_call(self.oneok_crm_url, "/crm/upsert_client", payload) async def _crm_upsert_site(self, args: Dict) -> ToolResult: payload = (args or {}).get("site_payload") if not isinstance(payload, dict): return ToolResult(success=False, result=None, error="site_payload is required") return await self._oneok_http_call(self.oneok_crm_url, "/crm/upsert_site", payload) async def _crm_upsert_window_unit(self, args: Dict) -> ToolResult: payload = (args or {}).get("window_payload") if not isinstance(payload, dict): return ToolResult(success=False, result=None, error="window_payload is required") return await self._oneok_http_call(self.oneok_crm_url, "/crm/upsert_window_unit", payload) async def _crm_create_quote(self, args: Dict) -> ToolResult: payload = (args or {}).get("quote_payload") if not isinstance(payload, dict): return ToolResult(success=False, result=None, error="quote_payload is required") return await self._oneok_http_call(self.oneok_crm_url, "/crm/create_quote", payload) async def _crm_update_quote(self, args: Dict) -> ToolResult: quote_id = (args or {}).get("quote_id") patch = (args or {}).get("patch") if not quote_id or not isinstance(patch, dict): return ToolResult(success=False, result=None, error="quote_id and patch are required") return await self._oneok_http_call(self.oneok_crm_url, "/crm/update_quote", {"quote_id": quote_id, "patch": patch}, method="PATCH") async def _crm_create_job(self, args: Dict) -> ToolResult: payload = (args or {}).get("job_payload") if not isinstance(payload, dict): return ToolResult(success=False, result=None, error="job_payload is required") return await self._oneok_http_call(self.oneok_crm_url, "/crm/create_job", payload) async def _calc_window_quote(self, args: Dict) -> ToolResult: payload = (args or {}).get("input_payload") if not isinstance(payload, dict): return ToolResult(success=False, result=None, error="input_payload is required") return await self._oneok_http_call(self.oneok_calc_url, "/calc/window_quote", payload) async def _docs_render_quote_pdf(self, args: Dict) -> ToolResult: quote_id = (args or {}).get("quote_id") quote_payload = (args or {}).get("quote_payload") if not quote_id and not isinstance(quote_payload, dict): return ToolResult(success=False, result=None, error="quote_id or quote_payload is required") payload = {"quote_id": quote_id, "quote_payload": quote_payload} return await self._oneok_http_call(self.oneok_docs_url, "/docs/render_quote_pdf", payload) async def _docs_render_invoice_pdf(self, args: Dict) -> ToolResult: payload = (args or {}).get("invoice_payload") if not isinstance(payload, dict): return ToolResult(success=False, result=None, error="invoice_payload is required") return await self._oneok_http_call(self.oneok_docs_url, "/docs/render_invoice_pdf", payload) async def _schedule_propose_slots(self, args: Dict) -> ToolResult: payload = (args or {}).get("params") if not isinstance(payload, dict): return ToolResult(success=False, result=None, error="params is required") return await self._oneok_http_call(self.oneok_schedule_url, "/schedule/propose_slots", payload) async def _schedule_confirm_slot(self, args: Dict) -> ToolResult: job_id = (args or {}).get("job_id") slot = (args or {}).get("slot") if not job_id or slot is None: return ToolResult(success=False, result=None, error="job_id and slot are required") return await self._oneok_http_call(self.oneok_schedule_url, "/schedule/confirm_slot", {"job_id": job_id, "slot": slot}) async def _data_governance_tool(self, args: Dict) -> ToolResult: """ Data Governance & Privacy scanner. Read-only; evidence is always masked before returning. """ action = (args or {}).get("action", "scan_repo") params = {k: v for k, v in (args or {}).items() if k != "action"} try: from data_governance import scan_data_governance_dict result = scan_data_governance_dict(action=action, params=params) return ToolResult(success=True, result=result) except Exception as e: logger.exception("data_governance_tool error") return ToolResult(success=False, result=None, error=f"Data governance error: {e}") async def _cost_analyzer_tool(self, args: Dict) -> ToolResult: """ Cost & Resource Analyzer (FinOps MVP). Reads from AuditStore and computes cost_units aggregations / anomalies. No payload access — only aggregation parameters. """ action = (args or {}).get("action", "top") params = {k: v for k, v in (args or {}).items() if k != "action"} try: from cost_analyzer import analyze_cost_dict report = analyze_cost_dict(action=action, params=params) return ToolResult(success=True, result=report) except Exception as e: logger.exception("cost_analyzer_tool error") return ToolResult(success=False, result=None, error=f"Cost analyzer error: {e}") async def _dependency_scanner_tool(self, args: Dict) -> ToolResult: """ Dependency & Supply Chain Scanner. Scans Python/Node dependencies for known vulnerabilities (OSV), outdated packages, and license policy violations. Security: - Read-only (no writes except optional cache update in online mode) - Evidence is redacted for secrets - Payload not logged; only hash + counts in audit - Timeout enforced """ action = (args or {}).get("action", "scan") if action != "scan": return ToolResult(success=False, result=None, error=f"Unknown action '{action}'. Valid: scan") targets = (args or {}).get("targets") or ["python", "node"] vuln_mode = (args or {}).get("vuln_mode", "offline_cache") fail_on = (args or {}).get("fail_on") or ["CRITICAL", "HIGH"] timeout_sec = float((args or {}).get("timeout_sec", 40)) REPO_ROOT = os.getenv("REPO_ROOT", "/Users/apple/github-projects/microdao-daarion") cache_path = os.path.join(REPO_ROOT, "ops", "cache", "osv_cache.json") try: from dependency_scanner import scan_dependencies_dict report = scan_dependencies_dict( repo_root=REPO_ROOT, targets=targets, vuln_sources={ "osv": { "enabled": True, "mode": vuln_mode, "cache_path": cache_path, } }, severity_thresholds={"fail_on": fail_on, "warn_on": ["MEDIUM"]}, timeout_sec=min(timeout_sec, 60.0), ) return ToolResult(success=True, result=report) except Exception as e: logger.exception("Dependency scanner error") return ToolResult(success=False, result=None, error=f"Dependency scan failed: {e}") async def _drift_analyzer_tool(self, args: Dict) -> ToolResult: """ Drift Analyzer Tool. Analyzes drift between 'sources of truth' (docs/inventory/config) and actual repo state (compose files, code routes, NATS usage, handlers). Security: - Read-only: never writes to repo - Evidence is redacted for secrets - Scans only within REPO_ROOT, excludes node_modules/.git/vendor - Timeout enforced """ action = (args or {}).get("action", "analyze") categories = (args or {}).get("categories") or None timeout_sec = float((args or {}).get("timeout_sec", 25)) if action != "analyze": return ToolResult( success=False, result=None, error=f"Unknown action '{action}'. Valid: analyze" ) REPO_ROOT = os.getenv("REPO_ROOT", "/Users/apple/github-projects/microdao-daarion") try: from drift_analyzer import analyze_drift_dict report = analyze_drift_dict( repo_root=REPO_ROOT, categories=categories, timeout_sec=min(timeout_sec, 30.0), ) return ToolResult(success=True, result=report) except Exception as e: logger.exception("Drift analyzer error") return ToolResult(success=False, result=None, error=f"Drift analysis failed: {e}") async def _repo_tool(self, args: Dict) -> ToolResult: """ Read-only repository access tool. Actions: - tree: Show directory structure - read: Read file contents - search: Search for text in files - metadata: Show git metadata Security: - Path traversal protection - Symlink escape protection - Size limits - Secret masking """ import os import re import subprocess from pathlib import Path action = (args or {}).get("action", "tree") path = (args or {}).get("path", ".") depth = min((args or {}).get("depth", 3), 10) # Max depth 10 glob_pattern = (args or {}).get("glob") query = (args or {}).get("query") limit = min((args or {}).get("limit", 50), 200) max_bytes = min((args or {}).get("max_bytes", 204800), 1024 * 1024) # Max 1MB start_line = (args or {}).get("start_line", 1) end_line = (args or {}).get("end_line") # Get repo root from environment or default to current directory repo_root = os.environ.get("REPO_ROOT", os.getcwd()) # Resolve the full path and check for traversal try: # Normalize and resolve the path full_path = os.path.normpath(os.path.join(repo_root, path)) # Check 1: Path must start with repo root (prevent traversal with ..) if not full_path.startswith(os.path.realpath(repo_root)): return ToolResult( success=False, result=None, error="Path traversal detected. Access denied." ) # Check 2: Check for symlink escape resolved_path = os.path.realpath(full_path) if not resolved_path.startswith(os.path.realpath(repo_root)): return ToolResult( success=False, result=None, error="Symlink escape detected. Access denied." ) # Check 3: Path must exist if not os.path.exists(full_path): return ToolResult( success=False, result=None, error=f"Path does not exist: {path}" ) except Exception as e: return ToolResult( success=False, result=None, error=f"Path validation error: {str(e)}" ) # Secret masking regex SECRET_PATTERNS = [ (re.compile(r'(?i)(api[_-]?key|token|secret|password|passwd|pwd|auth)[^=\s]*\s*=\s*[\'"]?(.+?)[\'"]?\s*$', re.MULTILINE), r'\1=***'), (re.compile(r'(?i)(bearer|jwt|oauth)[^=\s]*\s*[\'"]?(.+?)[\'"]?\s*$', re.MULTILINE), r'\1=***'), (re.compile(r'-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----'), '-----BEGIN PRIVATE KEY-----'), ] def mask_secrets(content: str) -> str: """Mask secrets in content""" for pattern, replacement in SECRET_PATTERNS: content = pattern.sub(replacement, content) return content def should_mask_file(path: str) -> bool: """Check if file should be masked (env, secrets, etc.)""" filename = os.path.basename(path).lower() mask_extensions = ['.env', '.env.local', '.env.production', '.env.development'] mask_names = ['secrets', 'credentials', 'keys', 'tokens', 'passwords'] if any(filename.endswith(ext) for ext in mask_extensions): return True if any(name in filename for name in mask_names): return True return False try: if action == "tree": # Build tree structure def build_tree(base_path: str, current_depth: int) -> dict: if current_depth > depth: return {"_truncated": True} result = {} try: entries = os.listdir(base_path) except PermissionError: return {"_error": "Permission denied"} # Sort entries - dirs first, then files dirs = [] files = [] for entry in entries: # Skip hidden files and common ignore patterns if entry.startswith('.') or entry in ['node_modules', '__pycache__', '.git', 'venv', '.venv']: continue full_entry_path = os.path.join(base_path, entry) if os.path.isdir(full_entry_path): dirs.append(entry) else: files.append(entry) for d in sorted(dirs): result[d] = build_tree(os.path.join(base_path, d), current_depth + 1) # Limit files shown for f in sorted(files)[:50]: # Max 50 files per directory result[f] = "[file]" return result tree = build_tree(full_path, 0) return ToolResult(success=True, result={"tree": tree, "path": path}) elif action == "read": # Check if it's a file if not os.path.isfile(full_path): return ToolResult( success=False, result=None, error="Path is not a file" ) # Check file size file_size = os.path.getsize(full_path) if file_size > max_bytes: return ToolResult( success=False, result=None, error=f"File too large: {file_size} bytes (max: {max_bytes})" ) # Check if should mask (env files, secrets, etc.) if should_mask_file(full_path): return ToolResult( success=True, result={ "path": path, "content": "[FILE MASKED - contains secrets]", "size": file_size, "masked": True } ) # Read file content with open(full_path, 'r', encoding='utf-8', errors='ignore') as f: lines = f.readlines() # Apply line limits if end_line: lines = lines[start_line-1:end_line] else: lines = lines[start_line-1:start_line-1 + 1000] # Default max 1000 lines content = ''.join(lines) # Mask any secrets in the content content = mask_secrets(content) return ToolResult( success=True, result={ "path": path, "content": content, "lines": len(lines), "start_line": start_line, "end_line": start_line + len(lines) - 1 } ) elif action == "search": if not query: return ToolResult( success=False, result=None, error="Query is required for search action" ) # Use grep via subprocess with limits cmd = [ "grep", "-r", "-n", "--max-count=" + str(limit), "--include=" + (glob_pattern if glob_pattern else "*"), query, path ] try: result = subprocess.run( cmd, cwd=repo_root, capture_output=True, text=True, timeout=10 ) # Parse grep output matches = [] for line in result.stdout.strip().split('\n'): if not line: continue # Format: filename:line:content parts = line.split(':', 2) if len(parts) >= 2: matches.append({ "file": parts[0], "line": parts[1] if len(parts) > 1 else "", "content": parts[2] if len(parts) > 2 else "" }) # Mask secrets in matches for m in matches: m["content"] = mask_secrets(m["content"]) return ToolResult( success=True, result={ "query": query, "path": path, "matches": matches, "count": len(matches) } ) except subprocess.TimeoutExpired: return ToolResult( success=False, result=None, error="Search timed out (>10s)" ) except Exception as e: return ToolResult( success=False, result=None, error=f"Search error: {str(e)}" ) elif action == "metadata": # Get git metadata meta = { "path": path, "repo_root": repo_root } try: # Git commit hash result = subprocess.run( ["git", "rev-parse", "HEAD"], cwd=repo_root, capture_output=True, text=True, timeout=5 ) if result.returncode == 0: meta["commit"] = result.stdout.strip() except: pass try: # Git branch result = subprocess.run( ["git", "branch", "--show-current"], cwd=repo_root, capture_output=True, text=True, timeout=5 ) if result.returncode == 0: meta["branch"] = result.stdout.strip() except: pass try: # Git status result = subprocess.run( ["git", "status", "--porcelain"], cwd=repo_root, capture_output=True, text=True, timeout=5 ) if result.returncode == 0: meta["dirty"] = bool(result.stdout.strip()) except: pass return ToolResult(success=True, result=meta) else: return ToolResult( success=False, result=None, error=f"Unknown action: {action}. Valid: tree, read, search, metadata" ) except Exception as e: logger.error(f"Repo tool error: {e}") return ToolResult( success=False, result=None, error=f"Internal error: {str(e)}" ) async def _pr_reviewer_tool(self, args: Dict) -> ToolResult: """ PR/Diff Reviewer Tool. Analyzes code changes, finds security issues, blocking problems, regression risks. Supports blocking_only and full_review modes. Security: - Does NOT log diff.text (only hash, file count, char count) - Masks secrets in evidence - Enforces max_files and max_chars limits """ import re import hashlib mode = (args or {}).get("mode", "full_review") context = (args or {}).get("context", {}) diff_data = (args or {}).get("diff", {}) options = (args or {}).get("options", {}) diff_text = diff_data.get("text", "") max_files = diff_data.get("max_files", 200) max_chars = diff_data.get("max_chars", 400000) # Log only metadata (no diff content) diff_hash = hashlib.sha256(diff_text.encode()).hexdigest()[:16] file_count = diff_text.count("diff --git") line_count = diff_text.count("\n") logger.info(f"PR Review: mode={mode}, files={file_count}, lines={line_count}, chars={len(diff_text)}, hash={diff_hash}") # Enforce limits if len(diff_text) > max_chars: return ToolResult( success=False, result=None, error=f"Diff too large: {len(diff_text)} chars (max: {max_chars})" ) if file_count > max_files: return ToolResult( success=False, result=None, error=f"Too many files: {file_count} (max: {max_files})" ) # Secret masking for evidence SECRET_PATTERN = re.compile( r'(?i)' r'(api[_-]?key|token|secret|password|passwd|pwd|auth|bearer|jwt|oauth|private[_-]?key)' r'[\s=:]+[\'"`]?([a-zA-Z0-9_\-\.]{8,})[\'"`]?', re.MULTILINE ) def mask_secret(text: str) -> str: """Mask secret values in text""" def replacer(match): key = match.group(1) return f"{key}=***" return SECRET_PATTERN.sub(replacer, text) # Blocking patterns for security analysis BLOCKING_PATTERNS = [ # Secrets in diff (r'(?i)(api[_-]?key|token|secret|password)\s*=\s*[\'"]?[a-zA-Z0-9_\-]{20,}[\'"]?', "SECRETS", "Secret detected in diff"), # Auth bypass (r'(?i)(if\s+.*auth.*:\s*return\s+True|#.*skip.*auth)', "AUTH_BYPASS", "Potential auth bypass"), # RCE/eval (r'(?i)(eval\(|exec\(|subprocess\.call|os\.system|shell=True)', "RCE", "Potential remote code execution"), # SQL injection (r'(?i)(execute\(|cursor\.execute|sql\s*\+|f"SELECT|\'.format\()', "SQL_INJECTION", "Potential SQL injection"), # Hardcoded credentials (r'(?i)(password\s*=\s*[\'"][^\'"]+[\'"]|pwd\s*=\s*[\'"][^\'"]+[\'"])', "HARDCODED_CREDS", "Hardcoded credentials"), # Disabled security (r'(?i)(#.*disable.*security|# noqa|SkipTest|skip_auth)', "SECURITY_DISABLED", "Security check disabled"), # Breaking API changes (simple heuristics) (r'(?i)(def\s+\w+\([^)]*\):[^}]*raise\s+NotImplemented)', "BREAKING_API", "Breaking API change without version"), ] # Non-blocking patterns NONBLOCKING_PATTERNS = [ (r'(?i)(# TODO|# FIXME|# XXX)', "TODO", "Technical debt"), (r'(?i)(except:.*pass|except Exception: pass)', "BROAD_EXCEPTION", "Broad exception handling"), (r'(?i)(print\(|logger\.)', "LOGGING", "Print/logging statement"), (r'(?i)(time\.sleep\(|asyncio\.sleep\()', "BLOCKING_SLEEP", "Blocking sleep"), ] blocking_issues = [] issues = [] security_findings = [] # Parse diff and analyze files = diff_text.split("diff --git") issue_id_counter = {"blocking": 1, "nonblocking": 1} for file_diff in files[1:]: # Skip first empty split if not file_diff.strip(): continue # Extract file path file_match = re.search(r'b/([^\s]+)', file_diff) file_path = file_match.group(1) if file_match else "unknown" # Extract changed lines added_lines = re.findall(r'^\+[^+].*$', file_diff, re.MULTILINE) removed_lines = re.findall(r'^-[^-].*$', file_diff, re.MULTILINE) # Check for blocking issues for pattern, issue_type, title in BLOCKING_PATTERNS: matches = re.finditer(pattern, file_diff) for match in matches: # Get line context line_start = file_diff.rfind('\n', 0, match.start()) + 1 line_end = file_diff.find('\n', match.start()) line_num = file_diff[:match.start()].count('\n') + 1 evidence = file_diff[line_start:line_end].strip()[:200] evidence = mask_secret(evidence) # Mask secrets in evidence issue = { "id": f"PRR-{issue_id_counter['blocking']:03d}", "title": title, "severity": "critical" if issue_type in ["SECRETS", "RCE", "SQL_INJECTION"] else "high", "file": file_path, "lines": f"L{line_num}", "evidence": evidence, "why_it_matters": self._get_why_matters(issue_type), "fix_suggestion": self._get_fix_suggestion(issue_type) } if issue_type in ["SECRETS", "RCE", "SQL_INJECTION"]: security_findings.append({ "type": issue_type.lower(), "finding": title, "mitigation": self._get_fix_suggestion(issue_type) }) blocking_issues.append(issue) issue_id_counter['blocking'] += 1 # Check for non-blocking issues (only in full_review mode) if mode == "full_review": for pattern, issue_type, title in NONBLOCKING_PATTERNS: matches = re.finditer(pattern, file_diff) for match in matches: line_num = file_diff[:match.start()].count('\n') + 1 evidence = file_diff[max(0, match.start()-20):match.end()+20].strip()[:200] issues.append({ "id": f"PRR-{100 + issue_id_counter['nonblocking']:03d}", "title": title, "severity": "medium", "file": file_path, "lines": f"L{line_num}", "why_it_matters": self._get_why_matters(issue_type), "fix_suggestion": self._get_fix_suggestion(issue_type) }) issue_id_counter['nonblocking'] += 1 # Check for missing tests if any(x in file_path for x in ['.py', '/services/', '/api/']) and 'test' not in file_path.lower(): if not any('test' in f for f in files): issues.append({ "id": f"PRR-{100 + issue_id_counter['nonblocking']:03d}", "title": "No test files in diff", "severity": "info", "file": file_path, "why_it_matters": "No test coverage for changes", "fix_suggestion": "Add unit/integration tests for the changes" }) issue_id_counter['nonblocking'] += 1 # Build response risk_score = min(100, len(blocking_issues) * 25 + len(issues) * 5) security_score = max(0, 100 - len(security_findings) * 30) # Generate summary if blocking_issues: summary = f"🚫 {len(blocking_issues)} blocking issue(s) found. {' '.join([i['title'] for i in blocking_issues[:3]])}" elif issues: summary = f"⚠️ {len(issues)} non-blocking issue(s) found. Review recommended." else: summary = "✅ No issues detected. Code looks good." result = { "summary": summary, "score": { "risk": risk_score, "maintainability": max(0, 100 - risk_score), "security": security_score, "test_coverage": 50 if any('test' in f for f in files) else 20 }, "blocking_issues": blocking_issues if mode == "blocking_only" else blocking_issues, "issues": [] if mode == "blocking_only" else issues, "regression_risks": self._assess_regression_risks(files, diff_text) if options.get("include_deploy_risks", True) else [], "security_findings": security_findings, "tests_checklist": self._generate_tests_checklist(file_count) if options.get("include_tests_checklist", True) else [], "deploy_checklist": self._generate_deploy_checklist() if options.get("include_deploy_risks", True) else [], "questions_for_author": self._generate_questions(blocking_issues, issues) } return ToolResult(success=True, result=result) def _get_why_matters(self, issue_type: str) -> str: """Get explanation of why this issue matters""" reasons = { "SECRETS": "Secrets in code can be exposed in logs, backups, and version control", "RCE": "Remote code execution allows attackers to run arbitrary commands", "SQL_INJECTION": "SQL injection can lead to data breach or data loss", "AUTH_BYPASS": "Auth bypass allows unauthorized access to protected resources", "HARDCODED_CREDS": "Hardcoded credentials are visible in source code", "SECURITY_DISABLED": "Disabling security checks creates vulnerabilities", "BREAKING_API": "Breaking API changes without versioning cause service disruptions", "TODO": "Technical debt should be tracked and addressed", "BROAD_EXCEPTION": "Catching all exceptions hides errors", "LOGGING": "Excessive logging may expose sensitive data", "BLOCKING_SLEEP": "Blocking sleep affects performance", } return reasons.get(issue_type, "This pattern may cause issues") def _get_fix_suggestion(self, issue_type: str) -> str: """Get fix suggestion for issue type""" fixes = { "SECRETS": "Use environment variables or secrets manager", "RCE": "Use parameterized queries, avoid shell=True", "SQL_INJECTION": "Use ORM or parameterized queries", "AUTH_BYPASS": "Ensure proper authentication checks", "HARDCODED_CREDS": "Move to environment variables or config", "SECURITY_DISABLED": "Re-enable security checks or document why disabled", "BREAKING_API": "Add version prefix or maintain backward compatibility", "TODO": "Create issue to track this task", "BROAD_EXCEPTION": "Catch specific exceptions", "LOGGING": "Use structured logging, avoid sensitive data", "BLOCKING_SLEEP": "Use async sleep or background tasks", } return fixes.get(issue_type, "Review and fix as appropriate") def _assess_regression_risks(self, files: list, diff_text: str) -> list: """Assess regression risks""" risks = [] # Check for gateway/router changes if any('router' in f or 'gateway' in f for f in files): risks.append({ "area": "gateway", "risk": "Changes to router may affect routing logic", "mitigation": "Test all known routes after deployment" }) # Check for DB migrations if 'migration' in diff_text.lower() or 'alter table' in diff_text.lower(): risks.append({ "area": "db", "risk": "Database schema changes may cause downtime", "mitigation": "Use rolling migrations, add fallback" }) # Check for auth changes if 'auth' in diff_text.lower() or 'permission' in diff_text.lower(): risks.append({ "area": "auth", "risk": "Auth changes may lock out users", "mitigation": "Test thoroughly, have rollback plan" }) return risks def _generate_tests_checklist(self, file_count: int) -> list: """Generate test checklist""" checklist = [ {"type": "unit", "item": "Add unit tests for new functions"}, {"type": "integration", "item": "Add integration tests for API endpoints"}, ] if file_count > 10: checklist.append({"type": "e2e", "item": "Consider E2E tests for critical flows"}) return checklist def _generate_deploy_checklist(self) -> list: """Generate deploy checklist""" return [ {"item": "Verify CI/CD pipeline passes"}, {"item": "Check backward compatibility"}, {"item": "Review security findings"}, {"item": "Ensure rollback plan is ready"}, ] def _generate_questions(self, blocking: list, issues: list) -> list: """Generate questions for PR author""" questions = [] if any('SECRETS' in i.get('title', '') for i in blocking): questions.append("How are secrets being managed?") if any('AUTH' in i.get('title', '') for i in blocking): questions.append("Are authentication changes properly tested?") if len(issues) > 5: questions.append("Can we address the non-blocking issues in a follow-up?") if not questions: questions.append("Is this ready for merge?") return questions async def _contract_tool(self, args: Dict) -> ToolResult: """ Contract Tool - OpenAPI/JSON Schema validation and diff. Actions: - lint_openapi: Static quality checks - diff_openapi: Breaking changes detection - generate_client_stub: Generate client stubs (MVP - basic) Security: - Does NOT log full OpenAPI specs - Enforces max_chars limit - Safe YAML/JSON parsing """ import re import hashlib import yaml import json action = (args or {}).get("action", "lint_openapi") inputs = (args or {}).get("inputs", {}) options = (args or {}).get("options", {}) max_chars = options.get("max_chars", 800000) service_name = options.get("service_name", "unknown") strict = options.get("strict", False) fail_on_breaking = options.get("fail_on_breaking", False) # Log only metadata (no spec content) base_val = inputs.get("base", {}).get("value", "") head_val = inputs.get("head", {}).get("value", "") base_hash = hashlib.sha256(base_val.encode()).hexdigest()[:16] if base_val else "none" head_hash = hashlib.sha256(head_val.encode()).hexdigest()[:16] if head_val else "none" logger.info(f"Contract Tool: action={action}, service={service_name}, base_hash={base_hash}, head_hash={head_hash}") # Enforce limits if base_val and len(base_val) > max_chars: return ToolResult(success=False, result=None, error=f"Base spec too large: {len(base_val)} chars (max: {max_chars})") if head_val and len(head_val) > max_chars: return ToolResult(success=False, result=None, error=f"Head spec too large: {len(head_val)} chars (max: {max_chars})") # Parse input helper def parse_spec(source: str, value: str, format_type: str) -> Optional[Dict]: """Parse OpenAPI spec from text or repo path""" if not value: return None if source == "repo_path": # Would use repo_tool here for MVP just return error asking for text return ToolResult(success=False, result=None, error="repo_path source not implemented in MVP, use text source") try: if format_type == "openapi_yaml" or "yaml" in value.lower()[:100]: return yaml.safe_load(value) else: return json.loads(value) except Exception as e: return None format_type = inputs.get("format", "openapi_yaml") # Execute action if action == "lint_openapi": return await self._lint_openapi(inputs, options, format_type) elif action == "diff_openapi": return await self._diff_openapi(inputs, options, format_type, fail_on_breaking) elif action == "generate_client_stub": return await self._generate_client_stub(inputs, options, format_type) else: return ToolResult(success=False, result=None, error=f"Unknown action: {action}") async def _lint_openapi(self, inputs: Dict, options: Dict, format_type: str) -> ToolResult: """Lint OpenAPI spec for quality issues""" import yaml import json spec_value = inputs.get("base", {}).get("value") or inputs.get("head", {}).get("value", "") if not spec_value: return ToolResult(success=False, result=None, error="No spec provided") # Parse spec try: if "yaml" in format_type: spec = yaml.safe_load(spec_value) else: spec = json.loads(spec_value) except Exception as e: return ToolResult(success=False, result=None, error=f"Failed to parse spec: {str(e)}") lint_issues = [] issue_id = 1 # Lint rules paths = spec.get("paths", {}) # Rule 1: Check for endpoints without operationId for path, methods in paths.items(): if not isinstance(methods, dict): continue for method, details in methods.items(): if method not in ["get", "post", "put", "delete", "patch", "options", "head"]: continue if not details.get("operationId"): lint_issues.append({ "id": f"OAL-{issue_id:03d}", "severity": "error", "message": f"Endpoint {method.upper()} {path} missing operationId", "location": f"paths.{path}.{method}" }) issue_id += 1 # Rule 2: Check for POST/PUT without requestBody schema if method in ["post", "put", "patch"]: if not details.get("requestBody"): lint_issues.append({ "id": f"OAL-{issue_id:03d}", "severity": "warning", "message": f"Endpoint {method.upper()} {path} missing requestBody", "location": f"paths.{path}.{method}" }) issue_id += 1 # Rule 3: Check for missing 2xx responses responses = details.get("responses", {}) has_2xx = any(code.startswith("2") for code in responses.keys()) if not has_2xx and responses: lint_issues.append({ "id": f"OAL-{issue_id:03d}", "severity": "warning", "message": f"Endpoint {method.upper()} {path} has no 2xx response", "location": f"paths.{path}.{method}.responses" }) issue_id += 1 # Rule 4: Check for unresolved $refs spec_str = json.dumps(spec) ref_matches = re.findall(r'\$ref:\s*["\']?([^"\'#]+)', spec_str) for ref in ref_matches: if "#" not in ref: # External refs not resolved lint_issues.append({ "id": f"OAL-{issue_id:03d}", "severity": "warning", "message": f"Unresolved external reference: {ref}", "location": "global" }) issue_id += 1 # Rule 5: Check for missing descriptions on critical paths critical_paths = ["/v1/auth", "/v1/users", "/v1/payments", "/v1/admin"] for path in paths: if any(critical in path for critical in critical_paths): for method, details in paths[path].items(): if method in ["get", "post", "put", "delete"]: if not details.get("description") and not details.get("summary"): lint_issues.append({ "id": f"OAL-{issue_id:03d}", "severity": "info", "message": f"Critical endpoint {method.upper()} {path} missing description", "location": f"paths.{path}.{method}" }) issue_id += 1 # Generate summary errors = [i for i in lint_issues if i["severity"] == "error"] warnings = [i for i in lint_issues if i["severity"] == "warning"] if errors: summary = f"🚫 {len(errors)} error(s), {len(warnings)} warning(s) found" elif warnings: summary = f"⚠️ {len(warnings)} warning(s) found" else: summary = "✅ OpenAPI spec looks good" result = { "summary": summary, "lint": lint_issues, "error_count": len(errors), "warning_count": len(warnings), "info_count": len([i for i in lint_issues if i["severity"] == "info"]), "compat_score": { "errors": len(errors), "warnings": len(warnings), "coverage": max(0, 100 - len(errors) * 10 - len(warnings) * 2) } } return ToolResult(success=True, result=result) async def _diff_openapi(self, inputs: Dict, options: Dict, format_type: str, fail_on_breaking: bool) -> ToolResult: """Compare two OpenAPI specs and detect breaking changes""" import yaml import json base_val = inputs.get("base", {}).get("value", "") head_val = inputs.get("head", {}).get("value", "") if not base_val or not head_val: return ToolResult(success=False, result=None, error="Both base and head specs required for diff") # Parse specs try: base_spec = yaml.safe_load(base_val) if "yaml" in format_type else json.loads(base_val) head_spec = yaml.safe_load(head_val) if "yaml" in format_type else json.loads(head_val) except Exception as e: return ToolResult(success=False, result=None, error=f"Failed to parse specs: {str(e)}") breaking = [] non_breaking = [] issue_id = {"breaking": 1, "nonbreaking": 1} base_paths = base_spec.get("paths", {}) head_paths = head_spec.get("paths", {}) # Check for removed endpoints for path, methods in base_paths.items(): if path not in head_paths: breaking.append({ "id": f"OAC-{issue_id['breaking']:03d}", "type": "endpoint_removed", "path": path, "method": "ALL", "location": f"paths.{path}", "why_it_breaks": f"Endpoint {path} was completely removed", "suggested_fix": "Ensure backward compatibility or version the API" }) issue_id['breaking'] += 1 continue base_methods = {k: v for k, v in methods.items() if k in ["get", "post", "put", "delete", "patch"]} head_methods = {k: v for k, v in head_paths[path].items() if k in ["get", "post", "put", "delete", "patch"]} # Check for removed methods for method in base_methods: if method not in head_methods: breaking.append({ "id": f"OAC-{issue_id['breaking']:03d}", "type": "endpoint_removed", "path": path, "method": method.upper(), "location": f"paths.{path}.{method}", "why_it_breaks": f"Method {method.upper()} was removed from {path}", "suggested_fix": "Deprecate instead of removing" }) issue_id['breaking'] += 1 # Check for required parameters added for method, base_details in base_methods.items(): if method not in head_methods: continue head_details = head_methods[method] base_params = base_details.get("parameters", []) head_params = head_details.get("parameters", []) base_required = {p["name"] for p in base_params if p.get("required", False)} head_required = {p["name"] for p in head_params if p.get("required", False)} # New required params = breaking for param in head_required - base_required: breaking.append({ "id": f"OAC-{issue_id['breaking']:03d}", "type": "required_added", "path": path, "method": method.upper(), "location": f"paths.{path}.{method}.parameters.{param}", "why_it_breaks": f"Required parameter '{param}' was added", "suggested_fix": "Make parameter optional or use default value" }) issue_id['breaking'] += 1 # Check for new endpoints for path in head_paths: if path not in base_paths: non_breaking.append({ "id": f"OAC-{100 + issue_id['nonbreaking']:03d}", "type": "endpoint_added", "details": f"New endpoint {path} added" }) issue_id['nonbreaking'] += 1 # Check for schemas base_schemas = base_spec.get("components", {}).get("schemas", {}) head_schemas = head_spec.get("components", {}).get("schemas", {}) for schema_name, base_schema in base_schemas.items(): if schema_name not in head_schemas: breaking.append({ "id": f"OAC-{issue_id['breaking']:03d}", "type": "schema_removed", "path": "", "method": "", "location": f"components.schemas.{schema_name}", "why_it_breaks": f"Schema {schema_name} was removed", "suggested_fix": "Deprecate schema instead of removing" }) issue_id['breaking'] += 1 continue head_schema = head_schemas[schema_name] base_props = base_schema.get("properties", {}) head_props = head_schema.get("properties", {}) # Check for required fields added base_required = base_schema.get("required", []) head_required = head_schema.get("required", []) for field in head_required: if field not in base_required: breaking.append({ "id": f"OAC-{issue_id['breaking']:03d}", "type": "required_added", "path": "", "method": "", "location": f"components.schemas.{schema_name}.required", "why_it_breaks": f"Required field '{field}' was added to schema {schema_name}", "suggested_fix": "Add field with default value or make optional" }) issue_id['breaking'] += 1 # Check for field types changed for field, field_def in head_props.items(): if field in base_props: base_type = base_props[field].get("type") head_type = field_def.get("type") if base_type != head_type: breaking.append({ "id": f"OAC-{issue_id['breaking']:03d}", "type": "schema_incompatible", "path": "", "method": "", "location": f"components.schemas.{schema_name}.{field}", "why_it_breaks": f"Field '{field}' type changed from {base_type} to {head_type}", "suggested_fix": "Use compatible type or version the schema" }) issue_id['breaking'] += 1 # Check for enum narrowing if "enum" in base_props: base_enum = set(base_props.get("enum", [])) head_enum = set(head_props.get("enum", [])) if head_enum < base_enum: # Proper subset breaking.append({ "id": f"OAC-{issue_id['breaking']:03d}", "type": "enum_narrowed", "path": "", "method": "", "location": f"components.schemas.{schema_name}.{field}", "why_it_breaks": f"Enum values narrowed: {head_enum} vs {base_enum}", "suggested_fix": "Add deprecated values as optional" }) issue_id['breaking'] += 1 # Generate summary if breaking: summary = f"🚫 {len(breaking)} breaking change(s) detected" elif non_breaking: summary = f"✅ {len(non_breaking)} non-breaking change(s), no breaking changes" else: summary = "✅ No changes detected" # Release checklist release_checklist = [] if breaking: release_checklist.append({"item": "⚠️ Breaking changes detected - requires version bump"}) release_checklist.append({"item": "Communicate changes to API consumers"}) else: release_checklist.append({"item": "✅ No breaking changes - safe to release"}) release_checklist.extend([ {"item": "Update API documentation"}, {"item": "Update client SDKs if applicable"}, {"item": "Test with existing clients"} ]) # Check fail_on_breaking if fail_on_breaking and breaking: return ToolResult( success=False, result={ "summary": summary, "breaking": breaking, "non_breaking": non_breaking, "compat_score": { "breaking_count": len(breaking), "warnings": len(non_breaking), "coverage": 0 }, "release_checklist": release_checklist }, error=f"Breaking changes detected: {len(breaking)}" ) result = { "summary": summary, "breaking": breaking, "non_breaking": non_breaking, "lint": [], "compat_score": { "breaking_count": len(breaking), "warnings": len(non_breaking), "coverage": max(0, 100 - len(breaking) * 25) }, "release_checklist": release_checklist } return ToolResult(success=True, result=result) async def _generate_client_stub(self, inputs: Dict, options: Dict, format_type: str) -> ToolResult: """Generate basic client stub (MVP - Python)""" import yaml import json spec_value = inputs.get("base", {}).get("value") or inputs.get("head", {}).get("value", "") if not spec_value: return ToolResult(success=False, result=None, error="No spec provided") # Parse spec try: spec = yaml.safe_load(spec_value) if "yaml" in format_type else json.loads(spec_value) except Exception as e: return ToolResult(success=False, result=None, error=f"Failed to parse spec: {str(e)}") # Get API info info = spec.get("info", {}) title = info.get("title", "API Client") version = info.get("version", "1.0.0") # Generate Python stub stub = f'''"""Auto-generated API Client for {title} v{version}""" import requests from typing import Optional, Dict, Any class {title.replace(" ", "").replace("-", "")}Client: """Client for {title}""" def __init__(self, base_url: str, api_key: Optional[str] = None): self.base_url = base_url.rstrip("/") self.api_key = api_key self.session = requests.Session() if api_key: self.session.headers["Authorization"] = f"Bearer {{api_key}}" ''' paths = spec.get("paths", {}) for path, methods in paths.items(): if not isinstance(methods, dict): continue for method, details in methods.items(): if method not in ["get", "post", "put", "delete", "patch"]: continue operation_id = details.get("operationId", f"{method}_{path.replace('/', '_')}") summary = details.get("summary", "") # Generate method signature params = [] for param in details.get("parameters", []): param_name = param.get("name", "param") param_required = param.get("required", False) param_type = param.get("schema", {}).get("type", "str") if not param_required: params.append(f"{param_name}: Optional[{param_type}] = None") else: params.append(f"{param_name}: {param_type}") params_str = ", ".join(params) if params else "" # Generate method docstring description = details.get("description", summary or f"{method.upper()} {path}") stub += f''' def {operation_id}(self, {params_str}) -> Dict[str, Any]: """{description} Endpoint: {method.upper()} {path} """ url = f"{{self.base_url}}{path}" ''' if method in ["post", "put", "patch"]: stub += ''' response = self.session.request("''' + method.upper() + '''", url, json=data) ''' else: stub += ''' response = self.session.request("''' + method.upper() + '''", url, params=params) ''' stub += ''' response.raise_for_status() return response.json() ''' result = { "summary": f"Generated Python client stub for {title}", "language": "python", "client_stub": stub, "info": { "title": title, "version": version, "endpoints": sum(len([m for m in methods if m in ["get", "post", "put", "delete", "patch"]]) for methods in paths.values() if isinstance(methods, dict)) } } return ToolResult(success=True, result=result) async def _oncall_tool(self, args: Dict, agent_id: str = None) -> ToolResult: """ Oncall/Runbook Tool - Operational information. Actions: - services_list: List all services - service_status: Get service status - service_health: Check health endpoint - deployments_recent: Recent deployments - runbook_search: Search runbooks - runbook_read: Read runbook - incident_log_list: List incidents - incident_log_append: Add incident (gated) Security: - Read-only except incident_log_append (gated) - Health checks only to allowlisted endpoints - Mask secrets in runbook content """ import re import os import json import glob from datetime import datetime, timedelta from pathlib import Path action = (args or {}).get("action") params = (args or {}).get("params", {}) repo_root = os.environ.get("REPO_ROOT", os.getcwd()) # RBAC: Check if agent can write incidents can_write_incident = agent_id in ["sofiia", "helion", "admin"] if agent_id else False # Allowlist directories for runbooks RUNBOOK_DIRS = ["ops", "runbooks", "docs/runbooks", "docs/ops"] # Allowlist for health endpoints (internal only) HEALTH_ALLOWLIST = [ "localhost", "127.0.0.1", "router-service", "gateway-service", "memory-service", "swapper-service", "crewai-service" ] def mask_secrets(content: str) -> str: """Mask secrets in content""" patterns = [ (re.compile(r'(?i)(api[_-]?key|token|secret|password)\s*=\s*.+'), r'\1=***'), ] for pattern, replacement in patterns: content = pattern.sub(replacement, content) return content try: if action == "services_list": # Parse docker-compose files to get services services = [] # Look for docker-compose files compose_files = glob.glob(os.path.join(repo_root, "docker-compose*.yml")) compose_files += glob.glob(os.path.join(repo_root, "*.yml")) for compose_file in compose_files[:5]: # Limit to 5 files try: with open(compose_file, 'r') as f: content = f.read() # Simple YAML parsing for service names if "services:" in content: lines = content.split("\n") for i, line in enumerate(lines): if line.strip().startswith("services:"): # Next lines are service names (indented) for j in range(i+1, min(i+50, len(lines))): svc_line = lines[j].strip() if svc_line and not svc_line.startswith("#"): if svc_line.endswith(":"): svc_name = svc_line.rstrip(":").strip() if svc_name and not svc_name.startswith("-"): services.append({ "name": svc_name, "source": os.path.basename(compose_file), "type": "service", "criticality": "medium" }) except: pass # Also check for service catalog catalog_files = glob.glob(os.path.join(repo_root, "*SERVICE_CATALOG*.md")) if catalog_files: try: with open(catalog_files[0], 'r') as f: content = f.read() # Look for service definitions for line in content.split("\n"): if "|" in line and "name" in line.lower(): parts = [p.strip() for p in line.split("|")] if len(parts) > 2 and parts[1]: services.append({ "name": parts[1], "source": "catalog", "type": "service", "criticality": "medium" }) except: pass return ToolResult(success=True, result={ "services": services[:50], # Limit to 50 "count": len(services) }) elif action == "service_health": service_name = params.get("service_name") health_endpoint = params.get("health_endpoint") if not health_endpoint: # Construct default health endpoint health_endpoint = f"http://{service_name}:8000/health" if service_name else None if not health_endpoint: return ToolResult(success=False, result=None, error="No health endpoint specified") # Validate endpoint is allowlisted allowed = False for host in HEALTH_ALLOWLIST: if host in health_endpoint: allowed = True break if not allowed: return ToolResult(success=False, result=None, error=f"Health endpoint not in allowlist: {HEALTH_ALLOWLIST}") # Make health check request try: response = await self.http_client.get(health_endpoint, timeout=3.0) return ToolResult(success=True, result={ "service": service_name, "endpoint": health_endpoint, "status": "healthy" if response.status_code < 400 else "unhealthy", "status_code": response.status_code, "latency_ms": int(response.elapsed.total_seconds() * 1000) }) except Exception as e: return ToolResult(success=True, result={ "service": service_name, "endpoint": health_endpoint, "status": "unreachable", "error": str(e) }) elif action == "service_status": service_name = params.get("service_name") # Try to get version from various sources version_info = { "service": service_name, "version": None, "last_seen": None } return ToolResult(success=True, result=version_info) elif action == "deployments_recent": # Try to read from ops/deployments.jsonl deploy_file = os.path.join(repo_root, "ops", "deployments.jsonl") deployments = [] if os.path.exists(deploy_file): try: with open(deploy_file, 'r') as f: for line in f: if line.strip(): try: deployments.append(json.loads(line)) except: pass except: pass # If no deployments file, use git as fallback if not deployments: try: import subprocess result = subprocess.run( ["git", "log", "--oneline", "-10"], cwd=repo_root, capture_output=True, text=True, timeout=5 ) if result.returncode == 0: for line in result.stdout.strip().split("\n"): if line: deployments.append({ "type": "git_commit", "commit": line }) except: pass return ToolResult(success=True, result={ "deployments": deployments[:20], "count": len(deployments) }) elif action == "runbook_search": query = params.get("query", "") results = [] # Search in allowed directories for runbook_dir in RUNBOOK_DIRS: full_dir = os.path.join(repo_root, runbook_dir) if os.path.exists(full_dir): try: for root, dirs, files in os.walk(full_dir): # Skip hidden dirs dirs[:] = [d for d in dirs if not d.startswith('.')] for file in files: if file.endswith(('.md', '.yml', '.yaml')): file_path = os.path.join(root, file) try: with open(file_path, 'r', errors='ignore') as f: content = f.read().lower() if query.lower() in content: rel_path = os.path.relpath(file_path, repo_root) results.append({ "path": rel_path, "file": file }) except: pass if len(results) >= 50: break except: pass return ToolResult(success=True, result={ "results": results[:20], "query": query, "count": len(results) }) elif action == "runbook_read": runbook_path = params.get("runbook_path") if not runbook_path: return ToolResult(success=False, result=None, error="runbook_path required") # Security: ensure path is within allowed directories allowed = False full_path = None for runbook_dir in RUNBOOK_DIRS: base_dir = os.path.join(repo_root, runbook_dir) if os.path.exists(base_dir): if runbook_path.startswith(runbook_dir) or runbook_path.startswith(runbook_dir.replace("docs/", "")): full_path = os.path.join(repo_root, runbook_path) allowed = True break # Also check direct path if not allowed: full_path = os.path.join(repo_root, runbook_path) for runbook_dir in RUNBOOK_DIRS: if os.path.commonpath([full_path, os.path.join(repo_root, runbook_dir)]) == os.path.join(repo_root, runbook_dir): allowed = True break if not allowed: return ToolResult(success=False, result=None, error="Runbook must be in allowed directories") # Check for path traversal real_path = os.path.realpath(full_path) if not real_path.startswith(os.path.realpath(repo_root)): return ToolResult(success=False, result=None, error="Path traversal detected") # Check file exists if not os.path.exists(real_path): return ToolResult(success=False, result=None, error="Runbook not found") # Read file try: with open(real_path, 'r', encoding='utf-8', errors='ignore') as f: content = f.read() # Limit size if len(content) > 200 * 1024: content = content[:200 * 1024] + "\n\n... [truncated]" # Mask secrets content = mask_secrets(content) return ToolResult(success=True, result={ "path": runbook_path, "content": content, "size": len(content) }) except Exception as e: return ToolResult(success=False, result=None, error=f"Error reading runbook: {str(e)}") # ── Incident CRUD (via incident_store) ──────────────────────── elif action == "incident_create": if not can_write_incident: return ToolResult(success=False, result=None, error="incident_create requires tools.oncall.incident_write") svc = params.get("service") or params.get("service_name") if not svc: return ToolResult(success=False, result=None, error="service is required") try: from incident_store import get_incident_store store = get_incident_store() inc = store.create_incident({ "workspace_id": params.get("workspace_id", "default"), "service": svc, "env": params.get("env", "prod"), "severity": params.get("severity", "P2"), "title": params.get("title", ""), "summary": params.get("summary", ""), "started_at": params.get("started_at", ""), "created_by": agent_id or "unknown", }) return ToolResult(success=True, result={ "incident_id": inc["id"], "status": inc["status"] }) except Exception as e: return ToolResult(success=False, result=None, error=f"incident_create failed: {e}") elif action == "incident_get": inc_id = params.get("incident_id") if not inc_id: return ToolResult(success=False, result=None, error="incident_id required") try: from incident_store import get_incident_store inc = get_incident_store().get_incident(inc_id) if not inc: return ToolResult(success=False, result=None, error=f"Incident {inc_id} not found") return ToolResult(success=True, result=inc) except Exception as e: return ToolResult(success=False, result=None, error=f"incident_get failed: {e}") elif action == "incident_list": try: from incident_store import get_incident_store filters = {} for k in ("status", "service", "env", "severity"): v = params.get(k) if v: filters[k] = v limit_val = min(int(params.get("limit", 50)), 200) incidents = get_incident_store().list_incidents(filters, limit=limit_val) return ToolResult(success=True, result={ "incidents": incidents, "count": len(incidents) }) except Exception as e: return ToolResult(success=False, result=None, error=f"incident_list failed: {e}") elif action == "incident_close": if not can_write_incident: return ToolResult(success=False, result=None, error="incident_close requires tools.oncall.incident_write") inc_id = params.get("incident_id") if not inc_id: return ToolResult(success=False, result=None, error="incident_id required") try: from incident_store import get_incident_store inc = get_incident_store().close_incident( inc_id, ended_at=params.get("ended_at", ""), resolution=params.get("resolution_summary", ""), ) if not inc: return ToolResult(success=False, result=None, error=f"Incident {inc_id} not found") return ToolResult(success=True, result={ "incident_id": inc_id, "status": "closed" }) except Exception as e: return ToolResult(success=False, result=None, error=f"incident_close failed: {e}") elif action == "incident_append_event": if not can_write_incident: return ToolResult(success=False, result=None, error="incident_append_event requires tools.oncall.incident_write") inc_id = params.get("incident_id") if not inc_id: return ToolResult(success=False, result=None, error="incident_id required") ev_type = params.get("type", "note") msg = params.get("message", "") meta = params.get("meta") try: from incident_store import get_incident_store ev = get_incident_store().append_event(inc_id, ev_type, msg, meta) if not ev: return ToolResult(success=False, result=None, error=f"Incident {inc_id} not found") return ToolResult(success=True, result={"event": ev}) except Exception as e: return ToolResult(success=False, result=None, error=f"incident_append_event failed: {e}") elif action == "incident_followups_summary": try: from datetime import datetime as _dt, timedelta as _td from incident_store import get_incident_store store = get_incident_store() svc = params.get("service") or params.get("service_name") or "" env = params.get("env", "any") window_days = min(int(params.get("window_days", 30)), 365) limit_val = min(int(params.get("limit", 100)), 500) filters: Dict = {} if svc: filters["service"] = svc if env and env != "any": filters["env"] = env incidents = store.list_incidents(filters, limit=limit_val) now_dt = _dt.utcnow() if window_days > 0: cutoff = now_dt - _td(days=window_days) incidents = [i for i in incidents if i.get("created_at", "") >= cutoff.isoformat()] open_critical = [ {"id": i["id"], "severity": i.get("severity"), "status": i.get("status"), "started_at": i.get("started_at"), "title": i.get("title", "")[:200]} for i in incidents if i.get("status") in ("open", "mitigating", "resolved") and i.get("severity") in ("P0", "P1") ] overdue = [] for inc in incidents: events = store.get_events(inc["id"], limit=200) for ev in events: if ev.get("type") != "followup": continue meta = ev.get("meta") or {} if isinstance(meta, str): try: meta = json.loads(meta) except Exception: meta = {} if meta.get("status", "open") != "open": continue due = meta.get("due_date", "") if due and due < now_dt.isoformat(): overdue.append({ "incident_id": inc["id"], "title": meta.get("title", ev.get("message", "")[:200]), "due_date": due, "priority": meta.get("priority", "P2"), "owner": meta.get("owner", ""), }) total_open = sum( 1 for inc in incidents for ev in store.get_events(inc["id"], limit=200) if ev.get("type") == "followup" and (ev.get("meta") or {}).get("status", "open") == "open" ) return ToolResult(success=True, result={ "open_incidents": open_critical[:20], "overdue_followups": overdue[:30], "stats": { "open_incidents": len(open_critical), "overdue": len(overdue), "total_open_followups": total_open, }, }) except Exception as e: return ToolResult(success=False, result=None, error=f"incident_followups_summary failed: {e}") elif action == "incident_attach_artifact": if not can_write_incident: return ToolResult(success=False, result=None, error="incident_attach_artifact requires tools.oncall.incident_write") inc_id = params.get("incident_id") if not inc_id: return ToolResult(success=False, result=None, error="incident_id required") content_b64 = params.get("content_base64", "") if not content_b64: return ToolResult(success=False, result=None, error="content_base64 required") kind = params.get("kind", "attachment") fmt = params.get("format", "json") fname = params.get("filename") or f"{kind}.{fmt}" try: from incident_artifacts import write_artifact, decode_content content_bytes = decode_content(content_b64) art_info = write_artifact(inc_id, fname, content_bytes) from incident_store import get_incident_store get_incident_store().add_artifact( inc_id, kind, fmt, art_info["path"], art_info["sha256"], art_info["size_bytes"], ) return ToolResult(success=True, result={"artifact": art_info}) except ValueError as ve: return ToolResult(success=False, result=None, error=str(ve)) except Exception as e: return ToolResult(success=False, result=None, error=f"incident_attach_artifact failed: {e}") elif action in ("signature_should_triage", "signature_mark_triage", "signature_mark_alert"): try: from signature_state_store import get_signature_state_store sig_store = get_signature_state_store() signature = params.get("signature", "") if not signature: return ToolResult(success=False, result=None, error="signature required") if action == "signature_should_triage": cooldown = int(params.get("cooldown_minutes", 15)) result_val = sig_store.should_run_triage(signature, cooldown) state = sig_store.get_state(signature) return ToolResult(success=True, result={ "should_triage": result_val, "signature": signature, "state": state, }) elif action == "signature_mark_triage": sig_store.mark_triage_run(signature) return ToolResult(success=True, result={ "signature": signature, "marked": "triage_run" }) else: # signature_mark_alert sig_store.mark_alert_seen(signature) return ToolResult(success=True, result={ "signature": signature, "marked": "alert_seen" }) except Exception as e: return ToolResult(success=False, result=None, error=f"signature action failed: {e}") elif action == "alert_to_incident": if not can_write_incident: return ToolResult(success=False, result=None, error="alert_to_incident requires tools.oncall.incident_write") try: from datetime import datetime as _dt, timedelta as _td from alert_store import get_alert_store from alert_ingest import map_alert_severity_to_incident from incident_store import get_incident_store from incident_artifacts import write_artifact alert_ref = params.get("alert_ref") if not alert_ref: return ToolResult(success=False, result=None, error="alert_ref required") astore = get_alert_store() alert = astore.get_alert(alert_ref) if not alert: return ToolResult(success=False, result=None, error=f"Alert {alert_ref} not found") sev_cap = params.get("incident_severity_cap", "P1") dedupe_win = int(params.get("dedupe_window_minutes", 60)) istore = get_incident_store() service = alert.get("service", "unknown") env = alert.get("env", "prod") kind = alert.get("kind", "custom") # Compute incident signature for precise deduplication import hashlib as _hl labels = alert.get("labels", {}) fp = labels.get("fingerprint", "") sig_raw = f"{service}|{env}|{kind}|{fp}" incident_signature = _hl.sha256(sig_raw.encode()).hexdigest()[:32] # Signature-based dedupe: reuse only if same signature cutoff = (_dt.utcnow() - _td(minutes=dedupe_win)).isoformat() existing_incs = istore.list_incidents( {"service": service, "env": env}, limit=20 ) # Find open incident with matching signature (stored in meta) open_inc = None for i in existing_incs: if i.get("status") not in ("open", "mitigating"): continue if i.get("severity") not in ("P0", "P1"): continue if i.get("started_at", "") < cutoff: continue # Check signature stored in incident meta inc_sig = (i.get("meta") or {}).get("incident_signature", "") if inc_sig and inc_sig == incident_signature: open_inc = i break if open_inc: incident_id = open_inc["id"] ev = istore.append_event( incident_id, "note", f"Alert re-triggered: {alert.get('title', '')}", meta={"alert_ref": alert_ref, "occurrences": alert.get("occurrences", 1), "incident_signature": incident_signature}, ) astore.ack_alert(alert_ref, args.get("agent_id", "oncall_tool"), note=f"incident:{incident_id}") return ToolResult(success=True, result={ "incident_id": incident_id, "created": False, "attached_event_id": None, "incident_signature": incident_signature, "note": "Attached to existing open incident (signature match)", }) # Create new incident sev = map_alert_severity_to_incident( alert.get("severity", "P2"), sev_cap ) inc = istore.create_incident({ "service": service, "env": env, "severity": sev, "title": alert.get("title", "Alert triggered"), "summary": alert.get("summary", ""), "started_at": alert.get("started_at") or _dt.utcnow().isoformat(), "created_by": args.get("agent_id", "oncall_tool"), "meta": {"incident_signature": incident_signature, "alert_ref": alert_ref, "alert_kind": kind, "alert_source": alert.get("source", "")}, }) incident_id = inc["id"] # Append timeline events istore.append_event(incident_id, "note", f"Created from alert {alert_ref}: {alert.get('title', '')}", meta={"alert_ref": alert_ref, "incident_signature": incident_signature, "source": alert.get("source", "")}) if alert.get("metrics"): istore.append_event(incident_id, "metric", "Alert metrics at detection", meta=alert["metrics"]) # Attach artifact (masked alert JSON) artifact_path = "" if params.get("attach_artifact", True): import base64 as _b64 safe_alert = { k: v for k, v in alert.items() if k not in ("evidence",) } safe_alert["evidence"] = { "log_samples": [], "note": "evidence redacted for artifact storage", } content = json.dumps(safe_alert, indent=2, default=str).encode() art = write_artifact( incident_id, f"alert_{alert_ref}.json", content, ) artifact_path = art.get("path", "") istore.add_artifact( incident_id, "alert", "json", artifact_path, art.get("sha256", ""), art.get("size_bytes", 0) ) astore.ack_alert(alert_ref, args.get("agent_id", "oncall_tool"), note=f"incident:{incident_id}") return ToolResult(success=True, result={ "incident_id": incident_id, "created": True, "severity": sev, "incident_signature": incident_signature, "artifact_path": artifact_path, "note": "Incident created and alert acked", }) except Exception as e: return ToolResult(success=False, result=None, error=f"alert_to_incident failed: {e}") else: return ToolResult( success=False, result=None, error=f"Unknown action: {action}" ) except Exception as e: logger.error(f"Oncall tool error: {e}") return ToolResult(success=False, result=None, error=f"Internal error: {str(e)}") async def _alert_ingest_tool(self, args: Dict) -> ToolResult: """ Alert Ingest Tool — alert dedup + state machine lifecycle. Actions: ingest — accept alert with dedupe (Monitor role: tools.alerts.ingest) list — list recent alerts by status (read: tools.alerts.read) get — get full alert (read) ack — mark acked (tools.alerts.ack) claim — atomic claim batch for processing (tools.alerts.claim) fail — mark processing failed, schedule retry (tools.alerts.ack) """ action = (args or {}).get("action") params = (args or {}).get("params", args or {}) from alert_store import get_alert_store from alert_ingest import ( ingest_alert, list_alerts, get_alert, ack_alert, ) store = get_alert_store() try: if action == "ingest": alert_data = params.get("alert") or params dedupe_ttl = int(params.get("dedupe_ttl_minutes", 30)) result = ingest_alert(store, alert_data, dedupe_ttl_minutes=dedupe_ttl) if not result.get("accepted", True): return ToolResult(success=False, result=None, error=result.get("error", "Validation failed")) return ToolResult(success=True, result=result) elif action == "list": service = params.get("service") env = params.get("env") window = int(params.get("window_minutes", 240)) limit = min(int(params.get("limit", 50)), 200) # Default to new+failed for operational list; pass explicit status_in if needed filters = {} if service: filters["service"] = service if env: filters["env"] = env filters["window_minutes"] = window status_in = params.get("status_in") if status_in: filters["status_in"] = status_in results = store.list_alerts(filters, limit=limit) return ToolResult(success=True, result={"alerts": results, "count": len(results)}) elif action == "get": alert_ref = params.get("alert_ref") if not alert_ref: return ToolResult(success=False, result=None, error="alert_ref required") rec = get_alert(store, alert_ref) if rec is None: return ToolResult(success=False, result=None, error=f"Alert {alert_ref} not found") return ToolResult(success=True, result=rec) elif action == "ack": alert_ref = params.get("alert_ref") if not alert_ref: return ToolResult(success=False, result=None, error="alert_ref required") actor = params.get("actor") or args.get("agent_id", "unknown") note = params.get("note", "") result = store.mark_acked(alert_ref, actor, note) if result is None: return ToolResult(success=False, result=None, error=f"Alert {alert_ref} not found") return ToolResult(success=True, result=result) elif action == "claim": window = int(params.get("window_minutes", 240)) limit = min(int(params.get("limit", 25)), 50) owner = params.get("owner") or args.get("agent_id", "loop") lock_ttl = int(params.get("lock_ttl_seconds", 600)) # Requeue any stale processing first (maintenance) requeued = store.requeue_expired_processing() if requeued > 0: logger.info("alert_ingest_tool.claim: requeued %d stale processing alerts", requeued) claimed = store.claim_next_alerts( window_minutes=window, limit=limit, owner=owner, lock_ttl_seconds=lock_ttl ) return ToolResult(success=True, result={ "alerts": claimed, "claimed": len(claimed), "requeued_stale": requeued, }) elif action == "fail": alert_ref = params.get("alert_ref") if not alert_ref: return ToolResult(success=False, result=None, error="alert_ref required") error_msg = params.get("error", "processing failed") retry_after = int(params.get("retry_after_seconds", 300)) result = store.mark_failed(alert_ref, error_msg, retry_after) if result is None: return ToolResult(success=False, result=None, error=f"Alert {alert_ref} not found") return ToolResult(success=True, result=result) else: return ToolResult(success=False, result=None, error=f"Unknown action: {action}") except Exception as e: logger.error("alert_ingest_tool error: %s", e) return ToolResult(success=False, result=None, error=f"Internal error: {e}") async def _incident_escalation_tool(self, args: Dict) -> ToolResult: """ Incident Escalation Engine (deterministic, 0 LLM tokens). evaluate — escalate open incidents based on occurrences/triage thresholds auto_resolve_candidates — find open incidents where alerts have gone quiet """ action = (args or {}).get("action") params = (args or {}).get("params", args or {}) try: from incident_escalation import ( evaluate_escalations, find_auto_resolve_candidates, load_escalation_policy ) from alert_store import get_alert_store from signature_state_store import get_signature_state_store from incident_store import get_incident_store alert_store = get_alert_store() sig_store = get_signature_state_store() istore = get_incident_store() policy = load_escalation_policy() dry_run = bool(params.get("dry_run", False)) if action == "evaluate": result = evaluate_escalations( params=params, alert_store=alert_store, sig_state_store=sig_store, incident_store=istore, policy=policy, dry_run=dry_run, ) return ToolResult(success=True, result=result) elif action == "auto_resolve_candidates": result = find_auto_resolve_candidates( params=params, sig_state_store=sig_store, incident_store=istore, policy=policy, dry_run=dry_run, ) return ToolResult(success=True, result=result) else: return ToolResult(success=False, result=None, error=f"Unknown action: {action}") except Exception as e: logger.error("incident_escalation_tool error: %s", e) return ToolResult(success=False, result=None, error=f"Internal error: {e}") async def _incident_intelligence_tool(self, args: Dict) -> ToolResult: """ Incident Intelligence Layer — deterministic, 0 LLM tokens. correlate — find related incidents by scored matching recurrence — frequency tables for a given window weekly_digest — full weekly md+json report saved to artifacts """ action = (args or {}).get("action") try: from incident_intelligence import ( correlate_incident, detect_recurrence, weekly_digest, load_intel_policy, ) from incident_store import get_incident_store istore = get_incident_store() policy = load_intel_policy() if action == "correlate": incident_id = (args or {}).get("incident_id") or "" if not incident_id: return ToolResult(success=False, result=None, error="incident_id is required for correlate") append_note = bool((args or {}).get("append_note", False)) related = correlate_incident( incident_id=incident_id, policy=policy, store=istore, append_note=append_note, ) return ToolResult(success=True, result={ "incident_id": incident_id, "related_count": len(related), "related": related, }) elif action == "recurrence": from incident_intelligence import recurrence_for_service window_days = int((args or {}).get("window_days", 7)) service = (args or {}).get("service", "") if service: stats = recurrence_for_service( service=service, window_days=window_days, policy=policy, store=istore, ) else: stats = detect_recurrence( window_days=window_days, policy=policy, store=istore, ) return ToolResult(success=True, result=stats) elif action == "buckets": from incident_intelligence import build_root_cause_buckets, _incidents_in_window window_days = int((args or {}).get("window_days", 30)) incidents = _incidents_in_window(istore, window_days) service = (args or {}).get("service", "") if service: incidents = [i for i in incidents if i.get("service", "") == service] result_buckets = build_root_cause_buckets( incidents=incidents, policy=policy, windows=[7, window_days], ) return ToolResult(success=True, result={ "service_filter": service or "all", "window_days": window_days, "bucket_count": len(result_buckets), "buckets": result_buckets, }) elif action == "weekly_digest": save_artifacts = bool((args or {}).get("save_artifacts", True)) result = weekly_digest( policy=policy, store=istore, save_artifacts=save_artifacts, ) return ToolResult(success=True, result={ "week": result["week"], "artifact_paths": result.get("artifact_paths", []), "markdown_preview": result["markdown"][:1200], "json_summary": { k: v for k, v in result["json_data"].items() if k in ("week", "generated_at", "open_incidents_count", "recent_7d_count", "recent_30d_count", "recommendations") }, }) else: return ToolResult(success=False, result=None, error=f"Unknown action: {action}") except Exception as e: logger.error("incident_intelligence_tool error: %s", e) return ToolResult(success=False, result=None, error=f"Internal error: {e}") async def _risk_engine_tool(self, args: Dict, agent_id: str = "ops") -> ToolResult: """ Risk Index Engine — deterministic, 0 LLM tokens. service — compute RiskReport for a single service dashboard — compute top-N risky services policy — return effective risk policy """ action = (args or {}).get("action") try: from risk_engine import ( load_risk_policy, compute_service_risk, compute_risk_dashboard, score_to_band, ) from incident_store import get_incident_store from incident_intelligence import recurrence_for_service policy = load_risk_policy() if action == "policy": return ToolResult(success=True, result=policy) env = (args or {}).get("env", "prod") window_hours = int((args or {}).get("window_hours", 24)) include_trend = (args or {}).get("include_trend", True) if action == "service": service = (args or {}).get("service", "") if not service: return ToolResult(success=False, result=None, error="service is required for service action") report = await self._fetch_service_risk( service, env, window_hours, policy, agent_id ) if include_trend: try: from risk_engine import enrich_risk_report_with_trend from risk_history_store import get_risk_history_store enrich_risk_report_with_trend(report, get_risk_history_store(), policy) except Exception as te: logger.warning("risk_engine_tool trend enrich failed: %s", te) return ToolResult(success=True, result=report) elif action == "dashboard": top_n = int((args or {}).get("top_n", 10)) services = await self._known_services() service_reports = [] for svc in services[:20]: try: r = await self._fetch_service_risk( svc, env, window_hours, policy, agent_id ) service_reports.append(r) except Exception as e: logger.warning("risk_dashboard: skip %s: %s", svc, e) history_store = None if include_trend: try: from risk_history_store import get_risk_history_store history_store = get_risk_history_store() except Exception: pass dashboard = compute_risk_dashboard( env=env, top_n=top_n, service_reports=service_reports, history_store=history_store, policy=policy, ) return ToolResult(success=True, result=dashboard) else: return ToolResult(success=False, result=None, error=f"Unknown action: {action}") except Exception as e: logger.error("risk_engine_tool error: %s", e) return ToolResult(success=False, result=None, error=f"Internal error: {e}") async def _fetch_service_risk( self, service: str, env: str, window_hours: int, policy: Dict, agent_id: str, ) -> Dict: """Fetch all signal data and compute RiskReport for a single service.""" from risk_engine import compute_service_risk from incident_store import get_incident_store from incident_intelligence import recurrence_for_service, load_intel_policy istore = get_incident_store() intel_policy = load_intel_policy() # ── Open incidents ──────────────────────────────────────────────────── open_incs: List[Dict] = [] try: filters = {"service": service, "env": env} open_incs = istore.list_incidents(filters, limit=50) open_incs = [i for i in open_incs if i.get("status") in ("open", "mitigating")] except Exception as e: logger.warning("risk fetch open_incidents failed: %s", e) # ── Recurrence ──────────────────────────────────────────────────────── rec_7d: Dict = {} rec_30d: Dict = {} try: rec_7d = recurrence_for_service(service=service, window_days=7, policy=intel_policy, store=istore) rec_30d = recurrence_for_service(service=service, window_days=30, policy=intel_policy, store=istore) except Exception as e: logger.warning("risk fetch recurrence failed: %s", e) # ── Follow-ups ──────────────────────────────────────────────────────── followups_data: Dict = {} try: fu_result = await self.execute_tool( "oncall_tool", {"action": "incident_followups_summary", "service": service, "env": env, "window_days": 30}, agent_id=agent_id, ) if fu_result.success: followups_data = fu_result.result or {} except Exception as e: logger.warning("risk fetch followups failed: %s", e) # ── SLO ─────────────────────────────────────────────────────────────── slo_data: Dict = {"violations": [], "skipped": True} try: slo_result = await self.execute_tool( "observability_tool", {"action": "slo_snapshot", "service": service, "env": env, "window_minutes": int(policy.get("defaults", {}).get("slo_window_minutes", 60))}, agent_id=agent_id, ) if slo_result.success: slo_data = slo_result.result or {} except Exception as e: logger.warning("risk fetch slo_snapshot failed: %s", e) # ── Alert-loop SLO (global, not per-service) ────────────────────────── alerts_loop_slo: Dict = {} try: from alert_store import get_alert_store from incident_escalation_policy import load_escalation_policy # noqa except ImportError: pass # optional try: alert_store = None from alert_store import get_alert_store alert_store = get_alert_store() alerts_loop_slo = alert_store.compute_loop_slo() or {} except Exception as e: logger.warning("risk fetch alerts_loop_slo failed: %s", e) # ── Escalations (count from decision events in recent incidents) ────── escalation_count = 0 try: cutoff = (datetime.datetime.utcnow() - datetime.timedelta(hours=window_hours)).isoformat() service_incs = istore.list_incidents({"service": service}, limit=50) for inc in service_incs: events = istore.get_events(inc["id"], limit=50) for ev in events: if (ev.get("type") == "decision" and "Escalat" in (ev.get("message") or "") and ev.get("ts", "") >= cutoff): escalation_count += 1 except Exception as e: logger.warning("risk fetch escalations failed: %s", e) return compute_service_risk( service=service, env=env, open_incidents=open_incs, recurrence_7d=rec_7d, recurrence_30d=rec_30d, followups_data=followups_data, slo_data=slo_data, alerts_loop_slo=alerts_loop_slo, escalation_count_24h=escalation_count, policy=policy, ) async def _risk_history_tool(self, args: Dict, agent_id: str = "ops") -> ToolResult: """ Risk History Tool — snapshot, cleanup, series, digest. RBAC: tools.risk.write (snapshot/cleanup/digest), tools.risk.read (series). """ action = (args or {}).get("action") try: from risk_engine import load_risk_policy, snapshot_all_services, enrich_risk_report_with_trend from risk_history_store import get_risk_history_store from risk_digest import daily_digest policy = load_risk_policy() history_store = get_risk_history_store() env = (args or {}).get("env", "prod") if action == "snapshot": services = await self._known_services() max_s = int(policy.get("history", {}).get("max_services_per_run", 50)) services = services[:max_s] def _sync_compute(svc: str, e: str): import asyncio loop = asyncio.new_event_loop() try: return loop.run_until_complete( self._fetch_service_risk(svc, e, 24, policy, agent_id) ) finally: loop.close() result = snapshot_all_services( env=env, compute_fn=_sync_compute, history_store=history_store, policy=policy, known_services=services, ) return ToolResult(success=True, result=result) elif action == "cleanup": retention_days = int( (args or {}).get("retention_days", policy.get("history", {}).get("retention_days", 90)) ) deleted = history_store.cleanup(retention_days) return ToolResult(success=True, result={"deleted": deleted, "retention_days": retention_days}) elif action == "series": service = (args or {}).get("service", "") if not service: return ToolResult(success=False, result=None, error="service is required for series action") hours = int((args or {}).get("hours", 168)) series = history_store.get_series(service, env, hours=hours, limit=200) return ToolResult(success=True, result={ "service": service, "env": env, "hours": hours, "count": len(series), "series": [s.to_dict() for s in series], }) elif action == "digest": services = await self._known_services() service_reports = [] for svc in services[:20]: try: r = await self._fetch_service_risk(svc, env, 24, policy, agent_id) enrich_risk_report_with_trend(r, history_store, policy) # Attribution (deterministic, non-fatal) try: from risk_engine import enrich_risk_report_with_attribution from incident_store import get_incident_store from alert_store import get_alert_store enrich_risk_report_with_attribution( r, alert_store=get_alert_store(), incident_store=get_incident_store(), ) except Exception as ae: logger.warning("digest attribution skip %s: %s", svc, ae) service_reports.append(r) except Exception as e: logger.warning("risk_history digest: skip %s: %s", svc, e) result = daily_digest( env=env, service_reports=service_reports, policy=policy, write_files=True, ) return ToolResult(success=True, result={ "date": result["date"], "env": result["env"], "json_path": result.get("json_path"), "md_path": result.get("md_path"), "markdown_preview": result["markdown"][:1500], "band_counts": result["json_data"].get("band_counts"), "top_regressions": result["json_data"].get("top_regressions"), "actions_count": len(result["json_data"].get("actions", [])), }) else: return ToolResult(success=False, result=None, error=f"Unknown action: {action}") except Exception as e: logger.error("risk_history_tool error: %s", e) return ToolResult(success=False, result=None, error=f"Internal error: {e}") async def _backlog_tool(self, args: Dict, agent_id: str = "ops") -> ToolResult: """ Engineering Backlog Tool — list/get/dashboard/create/upsert/set_status/ add_comment/close/auto_generate_weekly/cleanup. RBAC: tools.backlog.read/write/admin. """ action = (args or {}).get("action", "") try: from backlog_store import ( get_backlog_store, BacklogItem, BacklogEvent, validate_transition, _new_id, _now_iso, ) from backlog_generator import load_backlog_policy store = get_backlog_store() policy = load_backlog_policy() env = (args or {}).get("env", "prod") # ── READ ────────────────────────────────────────────────────────── if action == "list": filters = { k: (args or {}).get(k) for k in ("env", "service", "status", "owner", "category", "due_before") if (args or {}).get(k) } filters["env"] = filters.get("env") or env limit = int((args or {}).get("limit", 50)) offset = int((args or {}).get("offset", 0)) items = store.list_items(filters, limit=limit, offset=offset) return ToolResult(success=True, result={ "items": [it.to_dict() for it in items], "count": len(items), "filters": filters, }) elif action == "get": item_id = (args or {}).get("id", "") if not item_id: return ToolResult(success=False, result=None, error="id is required") item = store.get(item_id) if item is None: return ToolResult(success=False, result=None, error=f"Item not found: {item_id}") events = store.get_events(item_id, limit=20) return ToolResult(success=True, result={ "item": item.to_dict(), "events": [e.to_dict() for e in events], }) elif action == "dashboard": return ToolResult(success=True, result=store.dashboard(env=env)) # ── WRITE ───────────────────────────────────────────────────────── elif action in ("create", "upsert"): item_data = dict((args or {}).get("item") or {}) item_data.setdefault("env", env) item_data.setdefault("source", "manual") if not item_data.get("id"): item_data["id"] = _new_id("bl") item_data.setdefault("created_at", _now_iso()) item_data.setdefault("updated_at", _now_iso()) item = BacklogItem.from_dict(item_data) if action == "create": created = store.create(item) store.add_event(BacklogEvent( id=_new_id("ev"), item_id=created.id, ts=_now_iso(), type="created", message="Item created manually", actor=agent_id, )) return ToolResult(success=True, result={"action": "created", "item": created.to_dict()}) else: result = store.upsert(item) return ToolResult(success=True, result={ "action": result["action"], "item": result["item"].to_dict(), }) elif action == "set_status": item_id = (args or {}).get("id", "") new_status = (args or {}).get("status", "") message = (args or {}).get("message", "") if not item_id or not new_status: return ToolResult(success=False, result=None, error="id and status are required") item = store.get(item_id) if item is None: return ToolResult(success=False, result=None, error=f"Item not found: {item_id}") if not validate_transition(item.status, new_status, policy): return ToolResult(success=False, result=None, error=f"Invalid transition: {item.status} → {new_status}") old_status = item.status item.status = new_status item.updated_at = _now_iso() store.update(item) store.add_event(BacklogEvent( id=_new_id("ev"), item_id=item_id, ts=_now_iso(), type="status_change", message=message or f"Status: {old_status} → {new_status}", actor=agent_id, meta={"old_status": old_status, "new_status": new_status}, )) return ToolResult(success=True, result={"item": item.to_dict()}) elif action == "add_comment": item_id = (args or {}).get("id", "") message = (args or {}).get("message", "") if not item_id or not message: return ToolResult(success=False, result=None, error="id and message are required") item = store.get(item_id) if item is None: return ToolResult(success=False, result=None, error=f"Item not found: {item_id}") ev = store.add_event(BacklogEvent( id=_new_id("ev"), item_id=item_id, ts=_now_iso(), type="comment", message=message, actor=agent_id, )) return ToolResult(success=True, result={"event": ev.to_dict()}) elif action == "close": item_id = (args or {}).get("id", "") new_status = (args or {}).get("status", "done") if new_status not in ("done", "canceled"): new_status = "done" if not item_id: return ToolResult(success=False, result=None, error="id is required") item = store.get(item_id) if item is None: return ToolResult(success=False, result=None, error=f"Item not found: {item_id}") if not validate_transition(item.status, new_status, policy): return ToolResult(success=False, result=None, error=f"Cannot close: invalid transition {item.status} → {new_status}") item.status = new_status item.updated_at = _now_iso() store.update(item) store.add_event(BacklogEvent( id=_new_id("ev"), item_id=item_id, ts=_now_iso(), type="status_change", message=f"Closed as {new_status}", actor=agent_id, )) return ToolResult(success=True, result={"item": item.to_dict()}) # ── ADMIN ───────────────────────────────────────────────────────── elif action == "auto_generate_weekly": from backlog_generator import generate_from_pressure_digest import json week_str = (args or {}).get("week_str") # Load latest platform digest JSON digest_data: Optional[Dict] = None platform_dir = policy.get("digest", {}).get("output_dir", "ops/reports/platform") if hasattr(policy, "get") else "ops/reports/platform" # load pressure policy separately for digest output_dir try: from architecture_pressure import load_pressure_policy as _lpp _ap = _lpp() platform_dir = _ap.get("digest", {}).get("output_dir", "ops/reports/platform") except Exception: pass if week_str: digest_file = Path(platform_dir) / f"{week_str}.json" else: # Find latest pdir = Path(platform_dir) if pdir.exists(): files = sorted(pdir.glob("*.json"), reverse=True) digest_file = files[0] if files else None else: digest_file = None if digest_file and Path(digest_file).exists(): with open(digest_file, encoding="utf-8") as f: digest_data = json.load(f) else: return ToolResult(success=False, result=None, error="No platform digest found. Run architecture_pressure_tool.digest first.") result = generate_from_pressure_digest( digest_data, env=env, store=store, policy=policy, week_str=week_str, ) return ToolResult(success=True, result=result) elif action == "cleanup": retention_days = int( (args or {}).get("retention_days", policy.get("defaults", {}).get("retention_days", 180)) ) deleted = store.cleanup(retention_days) return ToolResult(success=True, result={ "deleted": deleted, "retention_days": retention_days }) else: return ToolResult(success=False, result=None, error=f"Unknown action: {action}") except Exception as e: logger.error("backlog_tool error: %s", e) return ToolResult(success=False, result=None, error=f"Internal error: {e}") async def _architecture_pressure_tool(self, args: Dict, agent_id: str = "ops") -> ToolResult: """ Architecture Pressure Tool — service, dashboard, digest. RBAC: tools.pressure.read (service/dashboard), tools.pressure.write (digest). """ action = (args or {}).get("action") try: from architecture_pressure import ( compute_pressure, compute_pressure_dashboard, load_pressure_policy, ) from incident_store import get_incident_store from alert_store import get_alert_store policy = load_pressure_policy() env = (args or {}).get("env", "prod") lookback_days = int((args or {}).get("lookback_days", policy.get("defaults", {}).get("lookback_days", 30))) inc_store = get_incident_store() al_store = get_alert_store() # Try to get risk_history_store for regression data risk_hist = None try: from risk_history_store import get_risk_history_store risk_hist = get_risk_history_store() except Exception: pass if action == "service": service = (args or {}).get("service", "") if not service: return ToolResult(success=False, result=None, error="service is required for service action") report = compute_pressure( service, env, lookback_days=lookback_days, policy=policy, incident_store=inc_store, alert_store=al_store, risk_history_store=risk_hist, ) return ToolResult(success=True, result=report) elif action == "dashboard": top_n = int((args or {}).get("top_n", policy.get("defaults", {}).get("top_n", 10))) services = await self._known_services() dashboard = compute_pressure_dashboard( env=env, services=services, top_n=top_n, policy=policy, incident_store=inc_store, alert_store=al_store, risk_history_store=risk_hist, ) return ToolResult(success=True, result=dashboard) elif action == "digest": from platform_priority_digest import weekly_platform_digest top_n = int((args or {}).get("top_n", policy.get("digest", {}).get("top_n_in_digest", 10))) auto_followup = bool((args or {}).get("auto_followup", True)) services = await self._known_services() # Build pressure reports for all services pressure_reports = [] for svc in services[:top_n * 2]: try: r = compute_pressure( svc, env, lookback_days=lookback_days, policy=policy, incident_store=inc_store, alert_store=al_store, risk_history_store=risk_hist, ) pressure_reports.append(r) except Exception as e: logger.warning("pressure digest: skip %s: %s", svc, e) result = weekly_platform_digest( env=env, pressure_reports=pressure_reports, policy=policy, write_files=True, auto_followup=auto_followup, incident_store=inc_store if auto_followup else None, ) return ToolResult(success=True, result={ "week": result["week"], "env": result["env"], "files_written": result["files_written"], "band_counts": result["band_counts"], "followups_created": result["followups_created"], "markdown_preview": result["markdown"][:1500], "investment_list": result["json_data"].get("investment_priority_list", []), }) else: return ToolResult(success=False, result=None, error=f"Unknown action: {action}") except Exception as e: logger.error("architecture_pressure_tool error: %s", e) return ToolResult(success=False, result=None, error=f"Internal error: {e}") async def _known_services(self) -> List[str]: """Return known service names from SLO policy.""" try: import yaml from pathlib import Path for p in [ Path("config/slo_policy.yml"), Path(__file__).resolve().parent.parent.parent / "config" / "slo_policy.yml", ]: if p.exists(): with open(p) as f: data = yaml.safe_load(f) or {} return list(data.get("services", {}).keys()) except Exception as e: logger.warning("_known_services: %s", e) return [] async def close(self): await self.http_client.aclose() def _strip_think_tags(text: str) -> str: """Remove ... tags from DeepSeek responses.""" import re text = re.sub(r'.*?', '', text, flags=re.DOTALL) text = re.sub(r'.*$', '', 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)}")