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
This commit is contained in:
Felipe Domingues 2026-05-30 14:05:17 -03:00 committed by GitHub
parent 11b664b8d5
commit 697825c505
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1077 additions and 38 deletions

View file

@ -21,6 +21,11 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 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 - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
__pycache__/
*.py[cod]
.pytest_cache/

View file

@ -60,6 +60,27 @@ RUN printf '%s\n' \
'model:' \ 'model:' \
' provider: "ollama-cloud"' \ ' provider: "ollama-cloud"' \
' default: "gemma4:31b-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:' \ 'stt:' \
' enabled: true' \ ' enabled: true' \
' provider: "local"' \ ' provider: "local"' \
@ -74,6 +95,7 @@ RUN printf '%s\n' \
'Default soul. Override via HERMES_SOUL_OVERRIDE.' \ '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 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 > /opt/data/.hermes/SOUL.md
# Cria o usuário hermes (esperado pelos scripts internos do Hermes) # Cria o usuário hermes (esperado pelos scripts internos do Hermes)

View file

@ -9,6 +9,7 @@ The Hermes gateway ignores environment variables for the primary model configura
## Configuration ## Configuration
The image pre-configures: The image pre-configures:
- **Provider**: `ollama-cloud` - **Provider**: `ollama-cloud`
- **Model**: `gemma4:31b-cloud` - **Model**: `gemma4:31b-cloud`
- **Config path**: `/opt/data/.hermes/config.yaml` - **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_SOUL_OVERRIDE=Your custom soul prompt here
HERMES_STT_PROVIDER=local HERMES_STT_PROVIDER=local
HERMES_TTS_PROVIDER=disabled HERMES_TTS_PROVIDER=disabled
HERMES_GATEWAY_ENABLED=auto
OLLAMA_API_KEY=your-ollama-api-key OLLAMA_API_KEY=your-ollama-api-key
PORT=8642 PORT=8642
TELEGRAM_BOT_TOKEN=your-bot-token TELEGRAM_BOT_TOKEN=your-bot-token
TELEGRAM_ALLOWED_USERS=your-user-id TELEGRAM_ALLOWED_USERS=your-user-id
TELEGRAM_HOME_CHANNEL=your-channel-id TELEGRAM_HOME_CHANNEL=your-channel-id
MIKA_AGENT_INSTANCE_ID=agent-instance-uuid
MIKA_PLATFORM_FUNCTIONS_BASE_URL=https://<project>.supabase.co/functions/v1
MIKA_CREATE_CRONJOB_URL=https://<project>.supabase.co/functions/v1/create-cronjob-from-agent
MIKA_CREATE_SKILL_URL=https://<project>.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 ## Deploy to Railway

View file

@ -15,6 +15,11 @@ STT_PROVIDER="${HERMES_STT_PROVIDER:-local}"
STT_LOCAL_MODEL="${HERMES_STT_LOCAL_MODEL:-base}" STT_LOCAL_MODEL="${HERMES_STT_LOCAL_MODEL:-base}"
STT_OPENAI_MODEL="${HERMES_STT_OPENAI_MODEL:-whisper-1}" STT_OPENAI_MODEL="${HERMES_STT_OPENAI_MODEL:-whisper-1}"
TTS_PROVIDER="${HERMES_TTS_PROVIDER:-disabled}" 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" mkdir -p "$HERMES_HOME"
@ -27,6 +32,27 @@ data_dir: /opt/data
model: model:
provider: "${MODEL_PROVIDER}" provider: "${MODEL_PROVIDER}"
default: "${MODEL_DEFAULT}" 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 EOF
case "${STT_PROVIDER}" in 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] Proxy público: $PUBLIC_HOST:$PUBLIC_PORT"
echo "[entrypoint] Iniciando Hermes original..." 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 \ hermes dashboard \
--host "$INTERNAL_HOST" \ --host "$INTERNAL_HOST" \
--port "$INTERNAL_PORT" \ --port "$INTERNAL_PORT" \
--no-open & --no-open &
HERMES_PID="$!" HERMES_PID="$!"
PIDS+=("$HERMES_PID")
echo "[entrypoint] Aguardando Hermes responder em $INTERNAL_HOST:$INTERNAL_PORT..." 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 exit 1
fi 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..." 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" \ --host "$PUBLIC_HOST" \
--port "$PUBLIC_PORT" \ --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"

View file

@ -1,4 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from __future__ import annotations
""" """
skills_api.py Reverse proxy + Runtime Sync API para Hermes Agent. 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: def _managed_skill_dirname(skill: dict) -> str:
raw_name = str(skill.get("name") or "skill").strip().lower() raw_name = str(skill.get("name") or "skill").strip()
slug = re.sub(r"[^a-z0-9._-]+", "-", raw_name).strip("-") or "skill" # Hermes' skill_view resolves bare names by directory name/path, not by
skill_id = str(skill.get("skill_id") or "manual").strip()[:8] or "manual" # frontmatter. Keep Mika-managed skill dirs aligned with the user-facing
return f"{slug}--{skill_id}" # 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: 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_status": existing_job.get("last_status") if existing_job else None,
"last_error": existing_job.get("last_error") 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, "last_delivery_error": existing_job.get("last_delivery_error") if existing_job else None,
"deliver": "local", "deliver": "origin",
"origin": None, "origin": existing_job.get("origin") if existing_job and existing_job.get("origin") else _telegram_home_origin(),
"enabled_toolsets": None, "enabled_toolsets": None,
"workdir": None, "workdir": None,
"managed_by": "mika", "managed_by": "mika",
@ -329,6 +353,14 @@ async def skills_sync(request: web.Request) -> web.Response:
} }
_write_json(SKILLS_MANIFEST_PATH, manifest, mode=0o644) _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({ return web.json_response({
"ok": True, "ok": True,
"received": len(incoming_skills), "received": len(incoming_skills),
@ -336,6 +368,8 @@ async def skills_sync(request: web.Request) -> web.Response:
"skipped": skipped, "skipped": skipped,
"removed": removed, "removed": removed,
"managed_skills_root": MANAGED_SKILLS_ROOT, "managed_skills_root": MANAGED_SKILLS_ROOT,
"reload": reload_result,
"reload_error": reload_error,
}) })

View file

@ -5,11 +5,14 @@ from plugins.mika_runtime.tools import (
CRONJOB_CREATE_SCHEMA, CRONJOB_CREATE_SCHEMA,
INTEGRATIONS_STATUS_SCHEMA, INTEGRATIONS_STATUS_SCHEMA,
NOTION_API_SCHEMA, NOTION_API_SCHEMA,
SKILL_CREATE_SCHEMA,
TODOIST_API_SCHEMA, TODOIST_API_SCHEMA,
handle_calcom_api, handle_calcom_api,
handle_cronjob_create, handle_cronjob_create,
handle_gateway_platform_action_intercept,
handle_integrations_status, handle_integrations_status,
handle_notion_api, handle_notion_api,
handle_skill_create,
handle_todoist_api, handle_todoist_api,
) )
@ -51,3 +54,14 @@ def register(ctx) -> None:
handler=handle_cronjob_create, handler=handle_cronjob_create,
emoji="", 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,
)

View file

@ -9,3 +9,4 @@ provides_tools:
- mika_todoist_api - mika_todoist_api
- mika_calcom_api - mika_calcom_api
- cronjob_create - cronjob_create
- skill_create

View file

@ -3,7 +3,12 @@
from __future__ import annotations from __future__ import annotations
import json import json
import asyncio
import logging
import os import os
import re
import threading
import unicodedata
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Iterable, Tuple from typing import Any, Dict, Iterable, Tuple
from urllib import error, parse, request from urllib import error, parse, request
@ -17,6 +22,21 @@ DEFAULT_NOTION_VERSION = "2022-06-28"
DEFAULT_CALCOM_VERSION = "2026-02-25" DEFAULT_CALCOM_VERSION = "2026-02-25"
MAX_RESPONSE_CHARS = 20000 MAX_RESPONSE_CHARS = 20000
USER_AGENT = "domco-mika-runtime/0.1" 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 = { INTEGRATIONS_STATUS_SCHEMA = {
"name": "mika_integrations_status", "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) 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]: def _load_manifest() -> dict[str, Any]:
if not INTEGRATIONS_MANIFEST_PATH.exists(): if not INTEGRATIONS_MANIFEST_PATH.exists():
return {"integrations": []} return {"integrations": []}
@ -535,61 +638,375 @@ CRONJOB_CREATE_SCHEMA = {
} }
def handle_cronjob_create(args: dict[str, Any], **_: Any) -> str: SKILL_CREATE_SCHEMA = {
supabase_url = os.environ.get("SUPABASE_URL", "").rstrip("/") "name": "skill_create",
internal_secret = os.environ.get("INTERNAL_FUNCTION_SECRET", "") "description": (
agent_instance_id = os.environ.get("AGENT_INSTANCE_ID", "") "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: 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: 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: 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() return endpoint, internal_secret, agent_instance_id
if not natural_language_input:
return "Erro: natural_language_input é obrigatório."
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 = { body_payload = {
"agent_instance_id": agent_instance_id, "agent_instance_id": agent_instance_id,
"natural_language_input": natural_language_input, **payload,
"name": name,
} }
payload = json.dumps(body_payload, ensure_ascii=False).encode("utf-8") body = json.dumps(body_payload, ensure_ascii=False).encode("utf-8")
headers = { headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
"x-internal-secret": internal_secret, "X-Internal-Secret": internal_secret,
"User-Agent": USER_AGENT, "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: try:
with request.urlopen(req, timeout=30) as response: with request.urlopen(req, timeout=45) as response:
raw_body = response.read() raw_body = response.read()
try: try:
data = json.loads(raw_body.decode("utf-8", errors="replace")) data = json.loads(raw_body.decode("utf-8", errors="replace"))
except Exception: except Exception:
data = {} data = {}
human_readable = data.get("human_readable") or data.get("description") or "" return int(response.status), data
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."
except error.HTTPError as exc: except error.HTTPError as exc:
raw_body = exc.read() raw_body = exc.read()
try: try:
data = json.loads(raw_body.decode("utf-8", errors="replace")) data = json.loads(raw_body.decode("utf-8", errors="replace"))
err_msg = data.get("error") or data.get("message") or str(exc)
except Exception: except Exception:
err_msg = str(exc) data = {"error": str(exc)}
return f"Erro ao criar automação: {err_msg}" return int(exc.code), data
except Exception as exc: 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"}

View file

@ -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()

View file

@ -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()

103
tests/test_skills_api.py Normal file
View file

@ -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()