mirror of
https://github.com/domfelipe/hermes-agent-custom.git
synced 2026-08-07 04:16:44 +00:00
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:
parent
11b664b8d5
commit
697825c505
12 changed files with 1077 additions and 38 deletions
5
.github/workflows/docker.yml
vendored
5
.github/workflows/docker.yml
vendored
|
|
@ -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
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.pytest_cache/
|
||||||
22
Dockerfile
22
Dockerfile
|
|
@ -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)
|
||||||
|
|
|
||||||
28
README.md
28
README.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -9,3 +9,4 @@ provides_tools:
|
||||||
- mika_todoist_api
|
- mika_todoist_api
|
||||||
- mika_calcom_api
|
- mika_calcom_api
|
||||||
- cronjob_create
|
- cronjob_create
|
||||||
|
- skill_create
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
|
|
|
||||||
257
tests/test_mika_runtime_tools.py
Normal file
257
tests/test_mika_runtime_tools.py
Normal 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()
|
||||||
68
tests/test_runtime_config.py
Normal file
68
tests/test_runtime_config.py
Normal 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
103
tests/test_skills_api.py
Normal 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()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue