"""
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'"
)
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)}")