From 697825c505d015c0b7c7275a4ddd845cc99021fe Mon Sep 17 00:00:00 2001 From: Felipe Domingues <53182096+domfelipe@users.noreply.github.com> Date: Sat, 30 May 2026 14:05:17 -0300 Subject: [PATCH] feat: post Mika cron and skill actions back to platform (#5) * feat: post mika actions back to platform * fix: start hermes gateway for telegram runtime * fix: isolate gateway from runtime api port * fix: enable mika runtime tools in gateway config * fix: prefer mika platform tools over native runtime tools * fix: intercept mika platform actions before llm * fix: route managed cronjobs and skills --- .github/workflows/docker.yml | 5 + .gitignore | 3 + Dockerfile | 22 ++ README.md | 28 ++ entrypoint.sh | 91 +++++- patches/skills_api.py | 46 ++- plugins/mika_runtime/__init__.py | 14 + plugins/mika_runtime/plugin.yaml | 1 + plugins/mika_runtime/tools.py | 477 +++++++++++++++++++++++++++++-- tests/test_mika_runtime_tools.py | 257 +++++++++++++++++ tests/test_runtime_config.py | 68 +++++ tests/test_skills_api.py | 103 +++++++ 12 files changed, 1077 insertions(+), 38 deletions(-) create mode 100644 .gitignore create mode 100644 tests/test_mika_runtime_tools.py create mode 100644 tests/test_runtime_config.py create mode 100644 tests/test_skills_api.py diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 3da9ec6..82d7412 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -21,6 +21,11 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Run runtime tests + run: | + python3 -m unittest discover -s tests -v + python3 -m compileall plugins patches tests + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a160f49 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ diff --git a/Dockerfile b/Dockerfile index 124c5e9..c7bb202 100644 --- a/Dockerfile +++ b/Dockerfile @@ -60,6 +60,27 @@ RUN printf '%s\n' \ 'model:' \ ' provider: "ollama-cloud"' \ ' default: "gemma4:31b-cloud"' \ + 'plugins:' \ + ' enabled:' \ + ' - mika_runtime' \ + 'platform_toolsets:' \ + ' telegram:' \ + ' - web' \ + ' - browser' \ + ' - terminal' \ + ' - file' \ + ' - code_execution' \ + ' - vision' \ + ' - image_gen' \ + ' - tts' \ + ' - todo' \ + ' - memory' \ + ' - session_search' \ + ' - clarify' \ + ' - delegation' \ + ' - messaging' \ + ' - computer_use' \ + ' - mika_integrations' \ 'stt:' \ ' enabled: true' \ ' provider: "local"' \ @@ -74,6 +95,7 @@ RUN printf '%s\n' \ 'Default soul. Override via HERMES_SOUL_OVERRIDE.' \ '' \ 'Quando o usuário pedir para agendar, lembrar ou automatizar algo recorrente, use a tool cronjob_create passando a frase original dele em natural_language_input.' \ + 'Quando o usuário pedir para criar, salvar ou ensinar uma skill nova, use a tool skill_create passando a frase original dele em natural_language_input.' \ > /opt/data/.hermes/SOUL.md # Cria o usuário hermes (esperado pelos scripts internos do Hermes) diff --git a/README.md b/README.md index 63a5944..d9b4f6f 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ The Hermes gateway ignores environment variables for the primary model configura ## Configuration The image pre-configures: + - **Provider**: `ollama-cloud` - **Model**: `gemma4:31b-cloud` - **Config path**: `/opt/data/.hermes/config.yaml` @@ -23,11 +24,38 @@ GATEWAY_ALLOW_ALL_USERS=false HERMES_SOUL_OVERRIDE=Your custom soul prompt here HERMES_STT_PROVIDER=local HERMES_TTS_PROVIDER=disabled +HERMES_GATEWAY_ENABLED=auto OLLAMA_API_KEY=your-ollama-api-key PORT=8642 TELEGRAM_BOT_TOKEN=your-bot-token TELEGRAM_ALLOWED_USERS=your-user-id TELEGRAM_HOME_CHANNEL=your-channel-id +MIKA_AGENT_INSTANCE_ID=agent-instance-uuid +MIKA_PLATFORM_FUNCTIONS_BASE_URL=https://.supabase.co/functions/v1 +MIKA_CREATE_CRONJOB_URL=https://.supabase.co/functions/v1/create-cronjob-from-agent +MIKA_CREATE_SKILL_URL=https://.supabase.co/functions/v1/create-skill-from-agent +MIKA_INTERNAL_FUNCTION_SECRET=shared-internal-secret +``` + +`MIKA_CREATE_CRONJOB_URL` and `MIKA_CREATE_SKILL_URL` are optional when +`MIKA_PLATFORM_FUNCTIONS_BASE_URL` or `SUPABASE_URL` is present. The Mika +platform provisions all of these automatically for managed Railway services. + +`HERMES_GATEWAY_ENABLED=auto` starts `hermes gateway run` whenever +`TELEGRAM_BOT_TOKEN` is present, so the container consumes Telegram polling in +addition to serving the dashboard/proxy runtime API. + +By default the entrypoint hides `API_SERVER_KEY` from the gateway subprocess so +Hermes does not start its native `api_server` adapter on the same public port as +the Mika runtime proxy. Set `HERMES_GATEWAY_API_SERVER_ENABLED=true` only if you +intentionally want the native adapter as a separate gateway platform. + +## Validation + +```bash +python3 -m unittest discover -s tests -v +python3 -m compileall plugins patches tests +git diff --check ``` ## Deploy to Railway diff --git a/entrypoint.sh b/entrypoint.sh index 9e8880b..0ad09ec 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -15,6 +15,11 @@ STT_PROVIDER="${HERMES_STT_PROVIDER:-local}" STT_LOCAL_MODEL="${HERMES_STT_LOCAL_MODEL:-base}" STT_OPENAI_MODEL="${HERMES_STT_OPENAI_MODEL:-whisper-1}" TTS_PROVIDER="${HERMES_TTS_PROVIDER:-disabled}" +GATEWAY_ENABLED="${HERMES_GATEWAY_ENABLED:-auto}" +GATEWAY_ARGS="${HERMES_GATEWAY_ARGS:---replace}" +GATEWAY_API_SERVER_ENABLED="${HERMES_GATEWAY_API_SERVER_ENABLED:-false}" +HERMES_ALLOW_ROOT_GATEWAY="${HERMES_ALLOW_ROOT_GATEWAY:-1}" +export HERMES_ALLOW_ROOT_GATEWAY mkdir -p "$HERMES_HOME" @@ -27,6 +32,27 @@ data_dir: /opt/data model: provider: "${MODEL_PROVIDER}" default: "${MODEL_DEFAULT}" +plugins: + enabled: + - mika_runtime +platform_toolsets: + telegram: + - web + - browser + - terminal + - file + - code_execution + - vision + - image_gen + - tts + - todo + - memory + - session_search + - clarify + - delegation + - messaging + - computer_use + - mika_integrations EOF case "${STT_PROVIDER}" in @@ -83,12 +109,27 @@ echo "[entrypoint] Hermes interno: $INTERNAL_HOST:$INTERNAL_PORT" echo "[entrypoint] Proxy público: $PUBLIC_HOST:$PUBLIC_PORT" echo "[entrypoint] Iniciando Hermes original..." +PIDS=() + +cleanup() { + local status=$? + trap - EXIT INT TERM + if [ "${#PIDS[@]}" -gt 0 ]; then + kill "${PIDS[@]}" 2>/dev/null || true + wait "${PIDS[@]}" 2>/dev/null || true + fi + exit "$status" +} + +trap cleanup EXIT INT TERM + hermes dashboard \ --host "$INTERNAL_HOST" \ --port "$INTERNAL_PORT" \ --no-open & HERMES_PID="$!" +PIDS+=("$HERMES_PID") echo "[entrypoint] Aguardando Hermes responder em $INTERNAL_HOST:$INTERNAL_PORT..." @@ -113,9 +154,55 @@ if ! kill -0 "$HERMES_PID" 2>/dev/null; then exit 1 fi +should_start_gateway=false +case "$GATEWAY_ENABLED" in + true|1|yes|on) + should_start_gateway=true + ;; + false|0|no|off) + should_start_gateway=false + ;; + auto|"") + if [ -n "${TELEGRAM_BOT_TOKEN:-}" ]; then + should_start_gateway=true + fi + ;; + *) + echo "[entrypoint] HERMES_GATEWAY_ENABLED inválido: $GATEWAY_ENABLED" + exit 1 + ;; +esac + +if [ "$should_start_gateway" = "true" ]; then + echo "[entrypoint] Iniciando gateway de mensagens Hermes..." + if [ "$GATEWAY_API_SERVER_ENABLED" = "true" ]; then + # shellcheck disable=SC2086 + hermes gateway run $GATEWAY_ARGS & + else + # The public runtime API is served by skills_api.py. Provisioned Mika + # instances still receive API_SERVER_KEY for that proxy, so hide it from + # the gateway process to avoid starting Hermes' native api_server adapter + # on the same public port. + # shellcheck disable=SC2086 + API_SERVER_ENABLED=false API_SERVER_KEY= hermes gateway run $GATEWAY_ARGS & + fi + GATEWAY_PID="$!" + PIDS+=("$GATEWAY_PID") +else + echo "[entrypoint] Gateway de mensagens desabilitado" +fi + echo "[entrypoint] Iniciando proxy público em $PUBLIC_HOST:$PUBLIC_PORT..." -exec python /opt/hermes-custom/skills_api.py \ +python /opt/hermes-custom/skills_api.py \ --host "$PUBLIC_HOST" \ --port "$PUBLIC_PORT" \ - --target "http://$INTERNAL_HOST:$INTERNAL_PORT" + --target "http://$INTERNAL_HOST:$INTERNAL_PORT" & + +PROXY_PID="$!" +PIDS+=("$PROXY_PID") + +wait -n "${PIDS[@]}" +EXITED_STATUS=$? +echo "[entrypoint] Um processo do runtime encerrou (status=$EXITED_STATUS); finalizando container" +exit "$EXITED_STATUS" diff --git a/patches/skills_api.py b/patches/skills_api.py index 845e7af..302d7ef 100644 --- a/patches/skills_api.py +++ b/patches/skills_api.py @@ -1,4 +1,6 @@ #!/usr/bin/env python3 +from __future__ import annotations + """ skills_api.py — Reverse proxy + Runtime Sync API para Hermes Agent. @@ -91,10 +93,32 @@ def _write_text(path: str, content: str, mode: int = 0o644) -> None: def _managed_skill_dirname(skill: dict) -> str: - raw_name = str(skill.get("name") or "skill").strip().lower() - slug = re.sub(r"[^a-z0-9._-]+", "-", raw_name).strip("-") or "skill" - skill_id = str(skill.get("skill_id") or "manual").strip()[:8] or "manual" - return f"{slug}--{skill_id}" + raw_name = str(skill.get("name") or "skill").strip() + # Hermes' skill_view resolves bare names by directory name/path, not by + # frontmatter. Keep Mika-managed skill dirs aligned with the user-facing + # skill name so `skill_view(name="Minha skill")` can load them immediately. + safe_name = re.sub(r"[\x00/\\]+", "-", raw_name) + safe_name = re.sub(r"\s+", " ", safe_name).strip(" .") + if not safe_name or safe_name in {".", ".."}: + return "skill" + return safe_name[:120] + + +def _telegram_home_origin() -> dict | None: + chat_id = os.environ.get("TELEGRAM_HOME_CHANNEL", "").strip() + if not chat_id: + return None + thread_id = ( + os.environ.get("TELEGRAM_CRON_THREAD_ID", "").strip() + or os.environ.get("TELEGRAM_HOME_CHANNEL_THREAD_ID", "").strip() + ) + origin = { + "platform": "telegram", + "chat_id": chat_id, + } + if thread_id: + origin["thread_id"] = thread_id + return origin def _managed_entry_name(slug_source: str, entry_id: str) -> str: @@ -215,8 +239,8 @@ def _sanitize_cron_runtime_job(raw_job: dict, existing_job: dict | None, synced_ "last_status": existing_job.get("last_status") if existing_job else None, "last_error": existing_job.get("last_error") if existing_job else None, "last_delivery_error": existing_job.get("last_delivery_error") if existing_job else None, - "deliver": "local", - "origin": None, + "deliver": "origin", + "origin": existing_job.get("origin") if existing_job and existing_job.get("origin") else _telegram_home_origin(), "enabled_toolsets": None, "workdir": None, "managed_by": "mika", @@ -329,6 +353,14 @@ async def skills_sync(request: web.Request) -> web.Response: } _write_json(SKILLS_MANIFEST_PATH, manifest, mode=0o644) + reload_result = None + reload_error = None + try: + from agent.skill_commands import reload_skills + reload_result = reload_skills() + except Exception as exc: + reload_error = str(exc) + return web.json_response({ "ok": True, "received": len(incoming_skills), @@ -336,6 +368,8 @@ async def skills_sync(request: web.Request) -> web.Response: "skipped": skipped, "removed": removed, "managed_skills_root": MANAGED_SKILLS_ROOT, + "reload": reload_result, + "reload_error": reload_error, }) diff --git a/plugins/mika_runtime/__init__.py b/plugins/mika_runtime/__init__.py index da23b5b..c0217d9 100644 --- a/plugins/mika_runtime/__init__.py +++ b/plugins/mika_runtime/__init__.py @@ -5,11 +5,14 @@ from plugins.mika_runtime.tools import ( CRONJOB_CREATE_SCHEMA, INTEGRATIONS_STATUS_SCHEMA, NOTION_API_SCHEMA, + SKILL_CREATE_SCHEMA, TODOIST_API_SCHEMA, handle_calcom_api, handle_cronjob_create, + handle_gateway_platform_action_intercept, handle_integrations_status, handle_notion_api, + handle_skill_create, handle_todoist_api, ) @@ -51,3 +54,14 @@ def register(ctx) -> None: handler=handle_cronjob_create, emoji="⏰", ) + ctx.register_tool( + name="skill_create", + toolset="mika_integrations", + schema=SKILL_CREATE_SCHEMA, + handler=handle_skill_create, + emoji="🧩", + ) + ctx.register_hook( + "pre_gateway_dispatch", + handle_gateway_platform_action_intercept, + ) diff --git a/plugins/mika_runtime/plugin.yaml b/plugins/mika_runtime/plugin.yaml index 972643a..5ffdb35 100644 --- a/plugins/mika_runtime/plugin.yaml +++ b/plugins/mika_runtime/plugin.yaml @@ -9,3 +9,4 @@ provides_tools: - mika_todoist_api - mika_calcom_api - cronjob_create + - skill_create diff --git a/plugins/mika_runtime/tools.py b/plugins/mika_runtime/tools.py index ad60cd7..83166e3 100644 --- a/plugins/mika_runtime/tools.py +++ b/plugins/mika_runtime/tools.py @@ -3,7 +3,12 @@ from __future__ import annotations import json +import asyncio +import logging import os +import re +import threading +import unicodedata from pathlib import Path from typing import Any, Dict, Iterable, Tuple from urllib import error, parse, request @@ -17,6 +22,21 @@ DEFAULT_NOTION_VERSION = "2022-06-28" DEFAULT_CALCOM_VERSION = "2026-02-25" MAX_RESPONSE_CHARS = 20000 USER_AGENT = "domco-mika-runtime/0.1" +logger = logging.getLogger(__name__) +AGENT_INSTANCE_ENV_NAMES = ( + "MIKA_AGENT_INSTANCE_ID", + "HERMES_AGENT_INSTANCE_ID", + "AGENT_INSTANCE_ID", +) +INTERNAL_SECRET_ENV_NAMES = ( + "MIKA_INTERNAL_FUNCTION_SECRET", + "HERMES_INTERNAL_FUNCTION_SECRET", + "INTERNAL_FUNCTION_SECRET", +) +GATEWAY_ACTION_INTERCEPT_ENV_NAMES = ( + "MIKA_GATEWAY_ACTION_INTERCEPT", + "HERMES_GATEWAY_ACTION_INTERCEPT", +) INTEGRATIONS_STATUS_SCHEMA = { "name": "mika_integrations_status", @@ -155,6 +175,89 @@ def _json_response(payload: dict[str, Any]) -> str: return json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) +def _is_truthy_env_default_true(*names: str) -> bool: + for name in names: + value = os.environ.get(name) + if value is None: + continue + lowered = str(value).strip().lower() + if lowered in {"0", "false", "no", "off", "disabled"}: + return False + if lowered in {"1", "true", "yes", "on", "enabled"}: + return True + return True + + +def _normalize_intent_text(value: Any) -> str: + text = str(value or "").strip().lower() + text = unicodedata.normalize("NFKD", text) + text = "".join(ch for ch in text if not unicodedata.combining(ch)) + return re.sub(r"\s+", " ", text) + + +_TEMPORAL_RE = re.compile( + r"(" + r"\bdaqui\s+(?:a\s+)?\d+\s*(?:min(?:uto)?s?|h(?:ora)?s?|dias?|semanas?)\b" + r"|\b(?:hoje|amanha|depois de amanha)\b" + r"|\b(?:todo|toda|todos|todas|diariamente|semanalmente|mensalmente|anualmente)\b" + r"|\b(?:segunda|terca|quarta|quinta|sexta|sabado|domingo)(?:-feira)?s?\b" + r"|\b(?:dia util|dias uteis|fim de semana)\b" + r"|\b(?:as|às)\s*\d{1,2}(?::\d{2}|h\d{0,2})?\b" + r"|\b\d{1,2}(?::\d{2}|h\d{0,2})\b" + r"|\b(?:cron|cronjob|automacao|automatizacao|lembrete|reminder|schedule)\b" + r")", + re.IGNORECASE, +) + +_CRON_INTENT_RE = re.compile( + r"(" + r"\bme\s+lemb(?:ra|re)\b" + r"|\blemb(?:ra|re)(?:-me)?\b" + r"|\bme\s+avis(?:a|e)\b" + r"|\bavis(?:a|e)(?:-me)?\b" + r"|\bagend(?:e|ar)\b" + r"|\bme\s+agenda\b" + r"|\bagenda\s+(?:um|uma|isso|para|pra)\b" + r"|\bprogram(?:a|e|ar)\b" + r"|\bautomatiz(?:a|e|ar)\b" + r"|\bcria(?:r)?\s+(?:um\s+|uma\s+)?(?:cronjob|lembrete|automacao)\b" + r"|\b(?:todo|toda|todos|todas)\b.*\b(?:manda|envia|me\s+manda|me\s+envia|resum[ao])\b" + r")", + re.IGNORECASE, +) + +_SKILL_INTENT_RE = re.compile( + r"(" + r"\bcria(?:r)?\s+(?:uma\s+|um\s+)?skill\b" + r"|\bcrie\s+(?:uma\s+|um\s+)?skill\b" + r"|\bnova\s+skill\b" + r"|\badicion(?:a|e|ar)\s+(?:uma\s+|um\s+)?skill\b" + r"|\bensina(?:r)?\b.*\b(?:skill|quando eu mandar|workflow|processo)\b" + r"|\bsalv(?:a|e|ar)\b.*\b(?:skill|workflow|processo)\b" + r")", + re.IGNORECASE, +) + + +def detect_gateway_platform_action(text: Any) -> str | None: + """Return a deterministic Mika platform action for explicit user intents.""" + normalized = _normalize_intent_text(text) + if not normalized: + return None + + # Plain slash commands should continue to Hermes/skills dispatch. + if normalized.startswith("/") and not _SKILL_INTENT_RE.search(normalized): + return None + + if _SKILL_INTENT_RE.search(normalized): + return "skill" + + if _CRON_INTENT_RE.search(normalized) and _TEMPORAL_RE.search(normalized): + return "cronjob" + + return None + + def _load_manifest() -> dict[str, Any]: if not INTEGRATIONS_MANIFEST_PATH.exists(): return {"integrations": []} @@ -535,61 +638,375 @@ CRONJOB_CREATE_SCHEMA = { } -def handle_cronjob_create(args: dict[str, Any], **_: Any) -> str: - supabase_url = os.environ.get("SUPABASE_URL", "").rstrip("/") - internal_secret = os.environ.get("INTERNAL_FUNCTION_SECRET", "") - agent_instance_id = os.environ.get("AGENT_INSTANCE_ID", "") +SKILL_CREATE_SCHEMA = { + "name": "skill_create", + "description": ( + "Creates a new custom Mika/Hermes skill via the Mika platform. Use this " + "when the user asks to teach the assistant a new procedure, add a new " + "skill, save a repeatable workflow, or turn instructions into a reusable " + "capability." + ), + "parameters": { + "type": "object", + "properties": { + "natural_language_input": { + "type": "string", + "description": ( + "The user's original request describing the skill to create." + ), + }, + "name": { + "type": "string", + "description": "A short skill name (optional).", + }, + "description": { + "type": "string", + "description": "A short description of what the skill does (optional).", + }, + "trigger_keywords": { + "type": "string", + "description": ( + "Comma-separated phrases that should trigger this skill (optional)." + ), + }, + "markdown_content": { + "type": "string", + "description": ( + "Full SKILL.md content if already drafted. If omitted, Mika " + "will generate a valid skill from natural_language_input." + ), + }, + }, + "required": ["natural_language_input"], + "additionalProperties": False, + }, +} + +def _first_env(*names: str) -> str: + for name in names: + value = os.environ.get(name, "").strip() + if value: + return value + return "" + + +def _platform_functions_base_url() -> str: + explicit = _first_env( + "MIKA_PLATFORM_FUNCTIONS_BASE_URL", + "HERMES_PLATFORM_FUNCTIONS_BASE_URL", + ).rstrip("/") + if explicit: + return explicit + + supabase_url = os.environ.get("SUPABASE_URL", "").strip().rstrip("/") if not supabase_url: - return "Erro: variável de ambiente SUPABASE_URL não configurada." + return "" + return f"{supabase_url}/functions/v1" + + +def _platform_endpoint(action: str) -> str: + if action == "cronjob": + explicit = _first_env("MIKA_CREATE_CRONJOB_URL", "HERMES_CREATE_CRONJOB_URL") + path = "create-cronjob-from-agent" + elif action == "skill": + explicit = _first_env("MIKA_CREATE_SKILL_URL", "HERMES_CREATE_SKILL_URL") + path = "create-skill-from-agent" + else: + raise ValueError(f"unknown platform action: {action}") + + if explicit: + return explicit + + base_url = _platform_functions_base_url() + if not base_url: + return "" + return f"{base_url}/{path}" + + +def _platform_auth_context(action: str) -> tuple[str, str, str] | str: + endpoint = _platform_endpoint(action) + internal_secret = _first_env(*INTERNAL_SECRET_ENV_NAMES) + agent_instance_id = _first_env(*AGENT_INSTANCE_ENV_NAMES) + + if not endpoint: + return ( + "Erro: endpoint da plataforma não configurado. Defina " + "MIKA_CREATE_CRONJOB_URL/MIKA_CREATE_SKILL_URL ou SUPABASE_URL." + ) if not internal_secret: - return "Erro: variável de ambiente INTERNAL_FUNCTION_SECRET não configurada." + return "Erro: segredo interno da plataforma não configurado." if not agent_instance_id: - return "Erro: variável de ambiente AGENT_INSTANCE_ID não configurada." + return "Erro: agent_instance_id da Mika não configurado no runtime." - natural_language_input = str(args.get("natural_language_input") or "").strip() - if not natural_language_input: - return "Erro: natural_language_input é obrigatório." + return endpoint, internal_secret, agent_instance_id - name = args.get("name") or None - url = f"{supabase_url}/functions/v1/create-cronjob-from-agent" +def _post_platform_action(action: str, payload: dict[str, Any]) -> tuple[int, dict[str, Any]]: + ctx = _platform_auth_context(action) + if isinstance(ctx, str): + return 0, {"ok": False, "error": ctx} + + url, internal_secret, agent_instance_id = ctx body_payload = { "agent_instance_id": agent_instance_id, - "natural_language_input": natural_language_input, - "name": name, + **payload, } - payload = json.dumps(body_payload, ensure_ascii=False).encode("utf-8") + body = json.dumps(body_payload, ensure_ascii=False).encode("utf-8") headers = { "Content-Type": "application/json", - "x-internal-secret": internal_secret, + "X-Internal-Secret": internal_secret, "User-Agent": USER_AGENT, } - req = request.Request(url=url, data=payload, method="POST", headers=headers) + req = request.Request(url=url, data=body, method="POST", headers=headers) try: - with request.urlopen(req, timeout=30) as response: + with request.urlopen(req, timeout=45) as response: raw_body = response.read() try: data = json.loads(raw_body.decode("utf-8", errors="replace")) except Exception: data = {} - human_readable = data.get("human_readable") or data.get("description") or "" - next_run_at = data.get("next_run_at") or "" - if human_readable: - msg = f"Automação criada: {human_readable}." - if next_run_at: - msg += f" Próxima execução: {next_run_at}." - return msg - return "Automação criada com sucesso." + return int(response.status), data except error.HTTPError as exc: raw_body = exc.read() try: data = json.loads(raw_body.decode("utf-8", errors="replace")) - err_msg = data.get("error") or data.get("message") or str(exc) except Exception: - err_msg = str(exc) - return f"Erro ao criar automação: {err_msg}" + data = {"error": str(exc)} + return int(exc.code), data except Exception as exc: - return f"Erro de rede ao criar automação: {exc}" + return 0, {"error": f"Erro de rede ao chamar plataforma: {exc}"} + + +def handle_cronjob_create(args: dict[str, Any], **_: Any) -> str: + natural_language_input = str(args.get("natural_language_input") or "").strip() + if not natural_language_input: + return "Erro: natural_language_input é obrigatório." + + payload = { + "natural_language_input": natural_language_input, + } + + name = args.get("name") or None + if name: + payload["name"] = str(name) + + status, data = _post_platform_action("cronjob", payload) + + if status < 200 or status >= 300 or data.get("success") is False: + err_msg = data.get("error") or data.get("message") or data.get("runtime_sync_error") + if not err_msg: + err_msg = f"HTTP {status}" if status else "falha desconhecida" + return f"Erro ao criar automação: {err_msg}" + + human_readable = data.get("human_readable") or data.get("description") or "" + next_run_at = data.get("next_run_at") or "" + if human_readable: + msg = f"Automação criada e sincronizada: {human_readable}." + if next_run_at: + msg += f" Próxima execução: {next_run_at}." + return msg + return "Automação criada e sincronizada com sucesso." + + +def handle_skill_create(args: dict[str, Any], **_: Any) -> str: + natural_language_input = str(args.get("natural_language_input") or "").strip() + if not natural_language_input: + return "Erro: natural_language_input é obrigatório." + + payload: dict[str, Any] = { + "natural_language_input": natural_language_input, + } + for key in ("name", "description", "trigger_keywords", "markdown_content"): + value = args.get(key) + if isinstance(value, str) and value.strip(): + payload[key] = value.strip() + + status, data = _post_platform_action("skill", payload) + + if status < 200 or status >= 300 or data.get("success") is False: + if data.get("skill_id") and data.get("runtime_sync_ok") is False: + return ( + "Skill criada na plataforma, mas ainda não sincronizada no runtime. " + f"Ela ficou em status {data.get('status') or 'testing'}. " + f"Erro: {data.get('runtime_sync_error') or 'sync falhou'}" + ) + err_msg = data.get("error") or data.get("message") + if not err_msg: + err_msg = f"HTTP {status}" if status else "falha desconhecida" + return f"Erro ao criar skill: {err_msg}" + + name = data.get("name") or "Skill" + synced_count = data.get("synced_count") + msg = f"Skill criada e sincronizada: {name}." + if synced_count is not None: + msg += f" Skills ativas sincronizadas: {synced_count}." + return msg + + +def _source_is_authorized_for_gateway_intercept(gateway: Any, source: Any) -> bool: + checker = getattr(gateway, "_is_user_authorized", None) + if not callable(checker): + return False + try: + return bool(checker(source)) + except Exception: + logger.debug("gateway auth check failed for Mika intercept", exc_info=True) + return False + + +def _gateway_thread_metadata(gateway: Any, event: Any) -> dict[str, Any] | None: + builder = getattr(gateway, "_thread_metadata_for_source", None) + if not callable(builder): + return None + try: + return builder(event.source, getattr(event, "message_id", None)) + except TypeError: + try: + return builder(event.source) + except Exception: + return None + except Exception: + return None + + +def _gateway_reply_anchor(gateway: Any, event: Any) -> str | None: + resolver = getattr(gateway, "_reply_anchor_for_event", None) + if callable(resolver): + try: + return resolver(event) + except Exception: + pass + message_id = getattr(event, "message_id", None) + return str(message_id) if message_id is not None else None + + +def _schedule_gateway_reply( + *, + loop: asyncio.AbstractEventLoop, + adapter: Any, + chat_id: str, + content: str, + reply_to: str | None, + metadata: dict[str, Any] | None, +) -> None: + async def _send() -> None: + await adapter.send( + chat_id=chat_id, + content=content, + reply_to=reply_to, + metadata=metadata, + ) + + future = asyncio.run_coroutine_threadsafe(_send(), loop) + + def _log_failure(done: Any) -> None: + try: + done.result() + except Exception: + logger.warning("failed to send Mika platform action reply", exc_info=True) + + future.add_done_callback(_log_failure) + + +def _run_gateway_platform_action( + *, + action: str, + natural_language_input: str, + loop: asyncio.AbstractEventLoop, + adapter: Any, + chat_id: str, + reply_to: str | None, + metadata: dict[str, Any] | None, +) -> None: + try: + if action == "cronjob": + content = handle_cronjob_create({ + "natural_language_input": natural_language_input, + }) + elif action == "skill": + content = handle_skill_create({ + "natural_language_input": natural_language_input, + }) + else: + content = "Erro: ação da plataforma não reconhecida." + except Exception as exc: + logger.warning("Mika platform action intercept failed", exc_info=True) + label = "automação" if action == "cronjob" else "skill" + content = f"Erro ao criar {label}: {exc}" + + _schedule_gateway_reply( + loop=loop, + adapter=adapter, + chat_id=chat_id, + content=content, + reply_to=reply_to, + metadata=metadata, + ) + + +def handle_gateway_platform_action_intercept( + *, + event: Any, + gateway: Any, + session_store: Any = None, +) -> dict[str, str] | None: + """Pre-gateway hook that makes Mika platform actions deterministic. + + The LLM can still use cronjob_create/skill_create as tools, but explicit + Telegram requests are also routed directly to Supabase before the model + runs. That keeps Supabase as the source of truth and avoids a natural + language "ok, vou lembrar" response that never persisted anything. + """ + del session_store + + if not _is_truthy_env_default_true(*GATEWAY_ACTION_INTERCEPT_ENV_NAMES): + return None + + if bool(getattr(event, "internal", False)): + return None + + source = getattr(event, "source", None) + if source is None or bool(getattr(source, "is_bot", False)): + return None + + text = str(getattr(event, "text", "") or "").strip() + action = detect_gateway_platform_action(text) + if not action: + return None + + if not _source_is_authorized_for_gateway_intercept(gateway, source): + return None + + adapters = getattr(gateway, "adapters", {}) or {} + adapter = adapters.get(getattr(source, "platform", None)) + chat_id = str(getattr(source, "chat_id", "") or "").strip() + if adapter is None or not chat_id: + logger.warning("Mika intercept could not find adapter/chat_id for action=%s", action) + return None + + try: + loop = asyncio.get_running_loop() + except RuntimeError: + logger.warning("Mika intercept has no running event loop") + return None + + thread = threading.Thread( + target=_run_gateway_platform_action, + kwargs={ + "action": action, + "natural_language_input": text, + "loop": loop, + "adapter": adapter, + "chat_id": chat_id, + "reply_to": _gateway_reply_anchor(gateway, event), + "metadata": _gateway_thread_metadata(gateway, event), + }, + name=f"mika-platform-action-{action}", + daemon=True, + ) + thread.start() + + return {"action": "skip", "reason": f"mika_{action}_handled"} diff --git a/tests/test_mika_runtime_tools.py b/tests/test_mika_runtime_tools.py new file mode 100644 index 0000000..c4f0299 --- /dev/null +++ b/tests/test_mika_runtime_tools.py @@ -0,0 +1,257 @@ +from __future__ import annotations + +import json +import os +import sys +import tempfile +import types +import unittest +import asyncio +from unittest import mock + + +_HERMES_HOME = tempfile.mkdtemp(prefix="mika-runtime-test-") +_hermes_constants = types.ModuleType("hermes_constants") +_hermes_constants.get_hermes_home = lambda: _HERMES_HOME +sys.modules.setdefault("hermes_constants", _hermes_constants) + +from plugins.mika_runtime import tools # noqa: E402 + + +class FakeResponse: + def __init__(self, status: int, payload: dict[str, object]): + self.status = status + self._payload = payload + self.headers = {"Content-Type": "application/json"} + + def __enter__(self) -> "FakeResponse": + return self + + def __exit__(self, *_args: object) -> None: + return None + + def read(self) -> bytes: + return json.dumps(self._payload).encode("utf-8") + + +class MikaRuntimePlatformActionTests(unittest.TestCase): + def test_detect_gateway_platform_action_for_cronjob_and_skill(self) -> None: + self.assertEqual( + tools.detect_gateway_platform_action( + "Mika, me lembra daqui 3 minutos de validar salvamento na plataforma" + ), + "cronjob", + ) + self.assertEqual( + tools.detect_gateway_platform_action( + "Mika, todo dia às 9h me manda um resumo da minha agenda" + ), + "cronjob", + ) + self.assertEqual( + tools.detect_gateway_platform_action( + "Mika, cria uma skill chamada teste-go-live que responda skill ativa" + ), + "skill", + ) + self.assertIsNone(tools.detect_gateway_platform_action("/teste_go_live")) + self.assertIsNone(tools.detect_gateway_platform_action("Qual é minha agenda hoje?")) + + def test_cronjob_create_posts_platform_contract(self) -> None: + captured = {} + + def fake_urlopen(req, timeout=0): + captured["url"] = req.full_url + captured["timeout"] = timeout + captured["secret"] = req.get_header("X-internal-secret") + captured["user_agent"] = req.get_header("User-agent") + captured["body"] = json.loads(req.data.decode("utf-8")) + return FakeResponse( + 200, + { + "success": True, + "human_readable": "toda segunda as 09:00", + "next_run_at": "2026-06-01T09:00:00Z", + }, + ) + + with mock.patch.dict( + os.environ, + { + "MIKA_AGENT_INSTANCE_ID": "agent-123", + "MIKA_INTERNAL_FUNCTION_SECRET": "secret-abc", + "MIKA_CREATE_CRONJOB_URL": "https://example.test/functions/v1/create-cronjob-from-agent", + }, + clear=True, + ), mock.patch.object(tools.request, "urlopen", side_effect=fake_urlopen): + result = tools.handle_cronjob_create({ + "natural_language_input": "toda segunda as 9h me envie um resumo", + "name": "Resumo semanal", + }) + + self.assertIn("Automação criada e sincronizada", result) + self.assertEqual( + captured["url"], + "https://example.test/functions/v1/create-cronjob-from-agent", + ) + self.assertEqual(captured["secret"], "secret-abc") + self.assertEqual(captured["user_agent"], tools.USER_AGENT) + self.assertEqual(captured["timeout"], 45) + self.assertEqual(captured["body"]["agent_instance_id"], "agent-123") + self.assertEqual( + captured["body"]["natural_language_input"], + "toda segunda as 9h me envie um resumo", + ) + self.assertEqual(captured["body"]["name"], "Resumo semanal") + + def test_skill_create_uses_supabase_url_fallback_and_reports_sync_failure(self) -> None: + captured = {} + + def fake_urlopen(req, timeout=0): + captured["url"] = req.full_url + captured["body"] = json.loads(req.data.decode("utf-8")) + return FakeResponse( + 502, + { + "success": False, + "skill_id": "skill-123", + "status": "testing", + "runtime_sync_ok": False, + "runtime_sync_error": "runtime offline", + }, + ) + + with mock.patch.dict( + os.environ, + { + "AGENT_INSTANCE_ID": "agent-456", + "INTERNAL_FUNCTION_SECRET": "secret-def", + "SUPABASE_URL": "https://project.supabase.co", + }, + clear=True, + ), mock.patch.object(tools.request, "urlopen", side_effect=fake_urlopen): + result = tools.handle_skill_create({ + "natural_language_input": "aprenda meu processo de pré-vendas", + "name": "Pré-vendas", + }) + + self.assertEqual( + captured["url"], + "https://project.supabase.co/functions/v1/create-skill-from-agent", + ) + self.assertEqual(captured["body"]["agent_instance_id"], "agent-456") + self.assertEqual(captured["body"]["name"], "Pré-vendas") + self.assertIn("Skill criada na plataforma", result) + self.assertIn("testing", result) + self.assertIn("runtime offline", result) + + def test_missing_platform_config_does_not_call_network(self) -> None: + with mock.patch.dict(os.environ, {}, clear=True), mock.patch.object( + tools.request, + "urlopen", + ) as urlopen: + result = tools.handle_cronjob_create({ + "natural_language_input": "me lembre todo dia", + }) + + urlopen.assert_not_called() + self.assertIn("endpoint da plataforma não configurado", result) + + +class FakeGatewaySendResult: + success = True + message_id = "sent-1" + + +class FakeGatewayAdapter: + def __init__(self) -> None: + self.sent: list[dict[str, object]] = [] + + async def send(self, chat_id, content, reply_to=None, metadata=None): + self.sent.append({ + "chat_id": chat_id, + "content": content, + "reply_to": reply_to, + "metadata": metadata, + }) + return FakeGatewaySendResult() + + +class FakeGateway: + def __init__(self, adapter: FakeGatewayAdapter) -> None: + self.adapters = {"telegram": adapter} + + def _is_user_authorized(self, source) -> bool: + return True + + def _thread_metadata_for_source(self, source, reply_to_message_id=None): + return {"thread_id": "topic-1", "reply": reply_to_message_id} + + def _reply_anchor_for_event(self, event): + return event.message_id + + +class MikaGatewayInterceptTests(unittest.IsolatedAsyncioTestCase): + async def test_gateway_intercept_handles_cronjob_without_llm_dispatch(self) -> None: + adapter = FakeGatewayAdapter() + gateway = FakeGateway(adapter) + event = types.SimpleNamespace( + text="Mika, me lembra daqui 3 minutos de validar salvamento", + message_id="msg-1", + internal=False, + source=types.SimpleNamespace( + platform="telegram", + chat_id="chat-1", + is_bot=False, + ), + ) + + with mock.patch.object( + tools, + "handle_cronjob_create", + return_value="Automação criada e sincronizada: daqui 3 minutos.", + ) as create: + result = tools.handle_gateway_platform_action_intercept( + event=event, + gateway=gateway, + session_store=None, + ) + for _ in range(100): + if adapter.sent: + break + await asyncio.sleep(0.01) + + self.assertEqual(result, {"action": "skip", "reason": "mika_cronjob_handled"}) + create.assert_called_once_with({ + "natural_language_input": "Mika, me lembra daqui 3 minutos de validar salvamento", + }) + self.assertEqual(adapter.sent[0]["chat_id"], "chat-1") + self.assertEqual(adapter.sent[0]["reply_to"], "msg-1") + self.assertIn("Automação criada", str(adapter.sent[0]["content"])) + + async def test_gateway_intercept_ignores_non_platform_action(self) -> None: + adapter = FakeGatewayAdapter() + gateway = FakeGateway(adapter) + event = types.SimpleNamespace( + text="/teste_go_live", + message_id="msg-2", + internal=False, + source=types.SimpleNamespace( + platform="telegram", + chat_id="chat-1", + is_bot=False, + ), + ) + + result = tools.handle_gateway_platform_action_intercept( + event=event, + gateway=gateway, + session_store=None, + ) + + self.assertIsNone(result) + self.assertEqual(adapter.sent, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_runtime_config.py b/tests/test_runtime_config.py new file mode 100644 index 0000000..b86590c --- /dev/null +++ b/tests/test_runtime_config.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from pathlib import Path +import unittest + + +ROOT = Path(__file__).resolve().parents[1] + + +class RuntimeConfigTests(unittest.TestCase): + def test_entrypoint_enables_mika_runtime_plugin_for_telegram(self) -> None: + entrypoint = (ROOT / "entrypoint.sh").read_text(encoding="utf-8") + + self.assertIn("plugins:\n enabled:\n - mika_runtime", entrypoint) + self.assertIn( + "platform_toolsets:\n" + " telegram:\n" + " - web\n" + " - browser\n" + " - terminal\n" + " - file\n" + " - code_execution\n" + " - vision\n" + " - image_gen\n" + " - tts\n" + " - todo\n" + " - memory\n" + " - session_search\n" + " - clarify\n" + " - delegation\n" + " - messaging\n" + " - computer_use\n" + " - mika_integrations", + entrypoint, + ) + self.assertNotIn(" - hermes-telegram", entrypoint) + self.assertNotIn(" - cronjob", entrypoint) + self.assertNotIn(" - skills", entrypoint) + + def test_dockerfile_fallback_config_matches_runtime_plugin_settings(self) -> None: + dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8") + + self.assertIn("'plugins:' \\", dockerfile) + self.assertIn("' - mika_runtime' \\", dockerfile) + self.assertIn("'platform_toolsets:' \\", dockerfile) + self.assertIn("' - web' \\", dockerfile) + self.assertIn("' - browser' \\", dockerfile) + self.assertIn("' - terminal' \\", dockerfile) + self.assertIn("' - file' \\", dockerfile) + self.assertIn("' - code_execution' \\", dockerfile) + self.assertIn("' - vision' \\", dockerfile) + self.assertIn("' - image_gen' \\", dockerfile) + self.assertIn("' - tts' \\", dockerfile) + self.assertIn("' - todo' \\", dockerfile) + self.assertIn("' - memory' \\", dockerfile) + self.assertIn("' - session_search' \\", dockerfile) + self.assertIn("' - clarify' \\", dockerfile) + self.assertIn("' - delegation' \\", dockerfile) + self.assertIn("' - messaging' \\", dockerfile) + self.assertIn("' - computer_use' \\", dockerfile) + self.assertIn("' - mika_integrations' \\", dockerfile) + self.assertNotIn("' - hermes-telegram' \\", dockerfile) + self.assertNotIn("' - cronjob' \\", dockerfile) + self.assertNotIn("' - skills' \\", dockerfile) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_skills_api.py b/tests/test_skills_api.py new file mode 100644 index 0000000..03450a6 --- /dev/null +++ b/tests/test_skills_api.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import importlib +import os +import sys +import types +import unittest +from unittest import mock + + +class SkillsApiSyncTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + fake_web = types.SimpleNamespace( + Request=object, + Response=object, + Application=object, + json_response=lambda payload, status=200: {"payload": payload, "status": status}, + run_app=lambda *_args, **_kwargs: None, + ) + fake_aiohttp = types.ModuleType("aiohttp") + fake_aiohttp.web = fake_web + fake_aiohttp.ClientSession = object + fake_aiohttp.ClientTimeout = object + sys.modules.setdefault("aiohttp", fake_aiohttp) + cls.skills_api = importlib.import_module("patches.skills_api") + + def test_managed_skill_dirname_matches_user_facing_skill_name(self) -> None: + dirname = self.skills_api._managed_skill_dirname({ + "skill_id": "ffdcdb9f-a52b-447b-ad47-57b390ce6c5d", + "name": "teste fechamento codex", + }) + + self.assertEqual(dirname, "teste fechamento codex") + + def test_managed_skill_dirname_removes_path_separators(self) -> None: + dirname = self.skills_api._managed_skill_dirname({ + "name": "../minha/skill\\nova", + }) + + self.assertEqual(dirname, "-minha-skill-nova") + + def test_managed_cronjobs_deliver_to_origin_with_telegram_home(self) -> None: + def fake_next_run(_schedule, _last_run_at): + return "2026-05-30T16:33:00+00:00" + + with mock.patch.dict( + os.environ, + { + "TELEGRAM_HOME_CHANNEL": "12345", + "TELEGRAM_CRON_THREAD_ID": "topic-1", + }, + clear=False, + ): + job = self.skills_api._sanitize_cron_runtime_job( + { + "job_id": "job-1", + "name": "validar fechamento final", + "action_prompt": "validar fechamento final", + "cron_expression": "*/3 * * * *", + "status": "active", + "human_readable": "A cada 3 minutos", + }, + None, + "2026-05-30T16:30:00Z", + fake_next_run, + ) + + self.assertIsNotNone(job) + self.assertEqual(job["deliver"], "origin") + self.assertEqual( + job["origin"], + {"platform": "telegram", "chat_id": "12345", "thread_id": "topic-1"}, + ) + + def test_existing_origin_is_preserved_on_resync(self) -> None: + def fake_next_run(_schedule, _last_run_at): + return "2026-05-30T16:33:00+00:00" + + job = self.skills_api._sanitize_cron_runtime_job( + { + "job_id": "job-1", + "name": "validar fechamento final", + "action_prompt": "validar fechamento final", + "cron_expression": "*/3 * * * *", + "status": "active", + "human_readable": "A cada 3 minutos", + }, + { + "origin": {"platform": "telegram", "chat_id": "existing"}, + "schedule": {"expr": "*/3 * * * *"}, + "next_run_at": "2026-05-30T16:36:00+00:00", + }, + "2026-05-30T16:30:00Z", + fake_next_run, + ) + + self.assertIsNotNone(job) + self.assertEqual(job["origin"], {"platform": "telegram", "chat_id": "existing"}) + + +if __name__ == "__main__": + unittest.main()