mirror of
https://github.com/domfelipe/hermes-agent-custom.git
synced 2026-08-07 05:56:45 +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
|
|
@ -3,7 +3,12 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import unicodedata
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, Tuple
|
||||
from urllib import error, parse, request
|
||||
|
|
@ -17,6 +22,21 @@ DEFAULT_NOTION_VERSION = "2022-06-28"
|
|||
DEFAULT_CALCOM_VERSION = "2026-02-25"
|
||||
MAX_RESPONSE_CHARS = 20000
|
||||
USER_AGENT = "domco-mika-runtime/0.1"
|
||||
logger = logging.getLogger(__name__)
|
||||
AGENT_INSTANCE_ENV_NAMES = (
|
||||
"MIKA_AGENT_INSTANCE_ID",
|
||||
"HERMES_AGENT_INSTANCE_ID",
|
||||
"AGENT_INSTANCE_ID",
|
||||
)
|
||||
INTERNAL_SECRET_ENV_NAMES = (
|
||||
"MIKA_INTERNAL_FUNCTION_SECRET",
|
||||
"HERMES_INTERNAL_FUNCTION_SECRET",
|
||||
"INTERNAL_FUNCTION_SECRET",
|
||||
)
|
||||
GATEWAY_ACTION_INTERCEPT_ENV_NAMES = (
|
||||
"MIKA_GATEWAY_ACTION_INTERCEPT",
|
||||
"HERMES_GATEWAY_ACTION_INTERCEPT",
|
||||
)
|
||||
|
||||
INTEGRATIONS_STATUS_SCHEMA = {
|
||||
"name": "mika_integrations_status",
|
||||
|
|
@ -155,6 +175,89 @@ def _json_response(payload: dict[str, Any]) -> str:
|
|||
return json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True)
|
||||
|
||||
|
||||
def _is_truthy_env_default_true(*names: str) -> bool:
|
||||
for name in names:
|
||||
value = os.environ.get(name)
|
||||
if value is None:
|
||||
continue
|
||||
lowered = str(value).strip().lower()
|
||||
if lowered in {"0", "false", "no", "off", "disabled"}:
|
||||
return False
|
||||
if lowered in {"1", "true", "yes", "on", "enabled"}:
|
||||
return True
|
||||
return True
|
||||
|
||||
|
||||
def _normalize_intent_text(value: Any) -> str:
|
||||
text = str(value or "").strip().lower()
|
||||
text = unicodedata.normalize("NFKD", text)
|
||||
text = "".join(ch for ch in text if not unicodedata.combining(ch))
|
||||
return re.sub(r"\s+", " ", text)
|
||||
|
||||
|
||||
_TEMPORAL_RE = re.compile(
|
||||
r"("
|
||||
r"\bdaqui\s+(?:a\s+)?\d+\s*(?:min(?:uto)?s?|h(?:ora)?s?|dias?|semanas?)\b"
|
||||
r"|\b(?:hoje|amanha|depois de amanha)\b"
|
||||
r"|\b(?:todo|toda|todos|todas|diariamente|semanalmente|mensalmente|anualmente)\b"
|
||||
r"|\b(?:segunda|terca|quarta|quinta|sexta|sabado|domingo)(?:-feira)?s?\b"
|
||||
r"|\b(?:dia util|dias uteis|fim de semana)\b"
|
||||
r"|\b(?:as|às)\s*\d{1,2}(?::\d{2}|h\d{0,2})?\b"
|
||||
r"|\b\d{1,2}(?::\d{2}|h\d{0,2})\b"
|
||||
r"|\b(?:cron|cronjob|automacao|automatizacao|lembrete|reminder|schedule)\b"
|
||||
r")",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_CRON_INTENT_RE = re.compile(
|
||||
r"("
|
||||
r"\bme\s+lemb(?:ra|re)\b"
|
||||
r"|\blemb(?:ra|re)(?:-me)?\b"
|
||||
r"|\bme\s+avis(?:a|e)\b"
|
||||
r"|\bavis(?:a|e)(?:-me)?\b"
|
||||
r"|\bagend(?:e|ar)\b"
|
||||
r"|\bme\s+agenda\b"
|
||||
r"|\bagenda\s+(?:um|uma|isso|para|pra)\b"
|
||||
r"|\bprogram(?:a|e|ar)\b"
|
||||
r"|\bautomatiz(?:a|e|ar)\b"
|
||||
r"|\bcria(?:r)?\s+(?:um\s+|uma\s+)?(?:cronjob|lembrete|automacao)\b"
|
||||
r"|\b(?:todo|toda|todos|todas)\b.*\b(?:manda|envia|me\s+manda|me\s+envia|resum[ao])\b"
|
||||
r")",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_SKILL_INTENT_RE = re.compile(
|
||||
r"("
|
||||
r"\bcria(?:r)?\s+(?:uma\s+|um\s+)?skill\b"
|
||||
r"|\bcrie\s+(?:uma\s+|um\s+)?skill\b"
|
||||
r"|\bnova\s+skill\b"
|
||||
r"|\badicion(?:a|e|ar)\s+(?:uma\s+|um\s+)?skill\b"
|
||||
r"|\bensina(?:r)?\b.*\b(?:skill|quando eu mandar|workflow|processo)\b"
|
||||
r"|\bsalv(?:a|e|ar)\b.*\b(?:skill|workflow|processo)\b"
|
||||
r")",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def detect_gateway_platform_action(text: Any) -> str | None:
|
||||
"""Return a deterministic Mika platform action for explicit user intents."""
|
||||
normalized = _normalize_intent_text(text)
|
||||
if not normalized:
|
||||
return None
|
||||
|
||||
# Plain slash commands should continue to Hermes/skills dispatch.
|
||||
if normalized.startswith("/") and not _SKILL_INTENT_RE.search(normalized):
|
||||
return None
|
||||
|
||||
if _SKILL_INTENT_RE.search(normalized):
|
||||
return "skill"
|
||||
|
||||
if _CRON_INTENT_RE.search(normalized) and _TEMPORAL_RE.search(normalized):
|
||||
return "cronjob"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _load_manifest() -> dict[str, Any]:
|
||||
if not INTEGRATIONS_MANIFEST_PATH.exists():
|
||||
return {"integrations": []}
|
||||
|
|
@ -535,61 +638,375 @@ CRONJOB_CREATE_SCHEMA = {
|
|||
}
|
||||
|
||||
|
||||
def handle_cronjob_create(args: dict[str, Any], **_: Any) -> str:
|
||||
supabase_url = os.environ.get("SUPABASE_URL", "").rstrip("/")
|
||||
internal_secret = os.environ.get("INTERNAL_FUNCTION_SECRET", "")
|
||||
agent_instance_id = os.environ.get("AGENT_INSTANCE_ID", "")
|
||||
SKILL_CREATE_SCHEMA = {
|
||||
"name": "skill_create",
|
||||
"description": (
|
||||
"Creates a new custom Mika/Hermes skill via the Mika platform. Use this "
|
||||
"when the user asks to teach the assistant a new procedure, add a new "
|
||||
"skill, save a repeatable workflow, or turn instructions into a reusable "
|
||||
"capability."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"natural_language_input": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"The user's original request describing the skill to create."
|
||||
),
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "A short skill name (optional).",
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "A short description of what the skill does (optional).",
|
||||
},
|
||||
"trigger_keywords": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Comma-separated phrases that should trigger this skill (optional)."
|
||||
),
|
||||
},
|
||||
"markdown_content": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Full SKILL.md content if already drafted. If omitted, Mika "
|
||||
"will generate a valid skill from natural_language_input."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["natural_language_input"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _first_env(*names: str) -> str:
|
||||
for name in names:
|
||||
value = os.environ.get(name, "").strip()
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def _platform_functions_base_url() -> str:
|
||||
explicit = _first_env(
|
||||
"MIKA_PLATFORM_FUNCTIONS_BASE_URL",
|
||||
"HERMES_PLATFORM_FUNCTIONS_BASE_URL",
|
||||
).rstrip("/")
|
||||
if explicit:
|
||||
return explicit
|
||||
|
||||
supabase_url = os.environ.get("SUPABASE_URL", "").strip().rstrip("/")
|
||||
if not supabase_url:
|
||||
return "Erro: variável de ambiente SUPABASE_URL não configurada."
|
||||
return ""
|
||||
return f"{supabase_url}/functions/v1"
|
||||
|
||||
|
||||
def _platform_endpoint(action: str) -> str:
|
||||
if action == "cronjob":
|
||||
explicit = _first_env("MIKA_CREATE_CRONJOB_URL", "HERMES_CREATE_CRONJOB_URL")
|
||||
path = "create-cronjob-from-agent"
|
||||
elif action == "skill":
|
||||
explicit = _first_env("MIKA_CREATE_SKILL_URL", "HERMES_CREATE_SKILL_URL")
|
||||
path = "create-skill-from-agent"
|
||||
else:
|
||||
raise ValueError(f"unknown platform action: {action}")
|
||||
|
||||
if explicit:
|
||||
return explicit
|
||||
|
||||
base_url = _platform_functions_base_url()
|
||||
if not base_url:
|
||||
return ""
|
||||
return f"{base_url}/{path}"
|
||||
|
||||
|
||||
def _platform_auth_context(action: str) -> tuple[str, str, str] | str:
|
||||
endpoint = _platform_endpoint(action)
|
||||
internal_secret = _first_env(*INTERNAL_SECRET_ENV_NAMES)
|
||||
agent_instance_id = _first_env(*AGENT_INSTANCE_ENV_NAMES)
|
||||
|
||||
if not endpoint:
|
||||
return (
|
||||
"Erro: endpoint da plataforma não configurado. Defina "
|
||||
"MIKA_CREATE_CRONJOB_URL/MIKA_CREATE_SKILL_URL ou SUPABASE_URL."
|
||||
)
|
||||
if not internal_secret:
|
||||
return "Erro: variável de ambiente INTERNAL_FUNCTION_SECRET não configurada."
|
||||
return "Erro: segredo interno da plataforma não configurado."
|
||||
if not agent_instance_id:
|
||||
return "Erro: variável de ambiente AGENT_INSTANCE_ID não configurada."
|
||||
return "Erro: agent_instance_id da Mika não configurado no runtime."
|
||||
|
||||
natural_language_input = str(args.get("natural_language_input") or "").strip()
|
||||
if not natural_language_input:
|
||||
return "Erro: natural_language_input é obrigatório."
|
||||
return endpoint, internal_secret, agent_instance_id
|
||||
|
||||
name = args.get("name") or None
|
||||
|
||||
url = f"{supabase_url}/functions/v1/create-cronjob-from-agent"
|
||||
def _post_platform_action(action: str, payload: dict[str, Any]) -> tuple[int, dict[str, Any]]:
|
||||
ctx = _platform_auth_context(action)
|
||||
if isinstance(ctx, str):
|
||||
return 0, {"ok": False, "error": ctx}
|
||||
|
||||
url, internal_secret, agent_instance_id = ctx
|
||||
body_payload = {
|
||||
"agent_instance_id": agent_instance_id,
|
||||
"natural_language_input": natural_language_input,
|
||||
"name": name,
|
||||
**payload,
|
||||
}
|
||||
payload = json.dumps(body_payload, ensure_ascii=False).encode("utf-8")
|
||||
body = json.dumps(body_payload, ensure_ascii=False).encode("utf-8")
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"x-internal-secret": internal_secret,
|
||||
"X-Internal-Secret": internal_secret,
|
||||
"User-Agent": USER_AGENT,
|
||||
}
|
||||
|
||||
req = request.Request(url=url, data=payload, method="POST", headers=headers)
|
||||
req = request.Request(url=url, data=body, method="POST", headers=headers)
|
||||
|
||||
try:
|
||||
with request.urlopen(req, timeout=30) as response:
|
||||
with request.urlopen(req, timeout=45) as response:
|
||||
raw_body = response.read()
|
||||
try:
|
||||
data = json.loads(raw_body.decode("utf-8", errors="replace"))
|
||||
except Exception:
|
||||
data = {}
|
||||
human_readable = data.get("human_readable") or data.get("description") or ""
|
||||
next_run_at = data.get("next_run_at") or ""
|
||||
if human_readable:
|
||||
msg = f"Automação criada: {human_readable}."
|
||||
if next_run_at:
|
||||
msg += f" Próxima execução: {next_run_at}."
|
||||
return msg
|
||||
return "Automação criada com sucesso."
|
||||
return int(response.status), data
|
||||
except error.HTTPError as exc:
|
||||
raw_body = exc.read()
|
||||
try:
|
||||
data = json.loads(raw_body.decode("utf-8", errors="replace"))
|
||||
err_msg = data.get("error") or data.get("message") or str(exc)
|
||||
except Exception:
|
||||
err_msg = str(exc)
|
||||
return f"Erro ao criar automação: {err_msg}"
|
||||
data = {"error": str(exc)}
|
||||
return int(exc.code), data
|
||||
except Exception as exc:
|
||||
return f"Erro de rede ao criar automação: {exc}"
|
||||
return 0, {"error": f"Erro de rede ao chamar plataforma: {exc}"}
|
||||
|
||||
|
||||
def handle_cronjob_create(args: dict[str, Any], **_: Any) -> str:
|
||||
natural_language_input = str(args.get("natural_language_input") or "").strip()
|
||||
if not natural_language_input:
|
||||
return "Erro: natural_language_input é obrigatório."
|
||||
|
||||
payload = {
|
||||
"natural_language_input": natural_language_input,
|
||||
}
|
||||
|
||||
name = args.get("name") or None
|
||||
if name:
|
||||
payload["name"] = str(name)
|
||||
|
||||
status, data = _post_platform_action("cronjob", payload)
|
||||
|
||||
if status < 200 or status >= 300 or data.get("success") is False:
|
||||
err_msg = data.get("error") or data.get("message") or data.get("runtime_sync_error")
|
||||
if not err_msg:
|
||||
err_msg = f"HTTP {status}" if status else "falha desconhecida"
|
||||
return f"Erro ao criar automação: {err_msg}"
|
||||
|
||||
human_readable = data.get("human_readable") or data.get("description") or ""
|
||||
next_run_at = data.get("next_run_at") or ""
|
||||
if human_readable:
|
||||
msg = f"Automação criada e sincronizada: {human_readable}."
|
||||
if next_run_at:
|
||||
msg += f" Próxima execução: {next_run_at}."
|
||||
return msg
|
||||
return "Automação criada e sincronizada com sucesso."
|
||||
|
||||
|
||||
def handle_skill_create(args: dict[str, Any], **_: Any) -> str:
|
||||
natural_language_input = str(args.get("natural_language_input") or "").strip()
|
||||
if not natural_language_input:
|
||||
return "Erro: natural_language_input é obrigatório."
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"natural_language_input": natural_language_input,
|
||||
}
|
||||
for key in ("name", "description", "trigger_keywords", "markdown_content"):
|
||||
value = args.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
payload[key] = value.strip()
|
||||
|
||||
status, data = _post_platform_action("skill", payload)
|
||||
|
||||
if status < 200 or status >= 300 or data.get("success") is False:
|
||||
if data.get("skill_id") and data.get("runtime_sync_ok") is False:
|
||||
return (
|
||||
"Skill criada na plataforma, mas ainda não sincronizada no runtime. "
|
||||
f"Ela ficou em status {data.get('status') or 'testing'}. "
|
||||
f"Erro: {data.get('runtime_sync_error') or 'sync falhou'}"
|
||||
)
|
||||
err_msg = data.get("error") or data.get("message")
|
||||
if not err_msg:
|
||||
err_msg = f"HTTP {status}" if status else "falha desconhecida"
|
||||
return f"Erro ao criar skill: {err_msg}"
|
||||
|
||||
name = data.get("name") or "Skill"
|
||||
synced_count = data.get("synced_count")
|
||||
msg = f"Skill criada e sincronizada: {name}."
|
||||
if synced_count is not None:
|
||||
msg += f" Skills ativas sincronizadas: {synced_count}."
|
||||
return msg
|
||||
|
||||
|
||||
def _source_is_authorized_for_gateway_intercept(gateway: Any, source: Any) -> bool:
|
||||
checker = getattr(gateway, "_is_user_authorized", None)
|
||||
if not callable(checker):
|
||||
return False
|
||||
try:
|
||||
return bool(checker(source))
|
||||
except Exception:
|
||||
logger.debug("gateway auth check failed for Mika intercept", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
def _gateway_thread_metadata(gateway: Any, event: Any) -> dict[str, Any] | None:
|
||||
builder = getattr(gateway, "_thread_metadata_for_source", None)
|
||||
if not callable(builder):
|
||||
return None
|
||||
try:
|
||||
return builder(event.source, getattr(event, "message_id", None))
|
||||
except TypeError:
|
||||
try:
|
||||
return builder(event.source)
|
||||
except Exception:
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _gateway_reply_anchor(gateway: Any, event: Any) -> str | None:
|
||||
resolver = getattr(gateway, "_reply_anchor_for_event", None)
|
||||
if callable(resolver):
|
||||
try:
|
||||
return resolver(event)
|
||||
except Exception:
|
||||
pass
|
||||
message_id = getattr(event, "message_id", None)
|
||||
return str(message_id) if message_id is not None else None
|
||||
|
||||
|
||||
def _schedule_gateway_reply(
|
||||
*,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
adapter: Any,
|
||||
chat_id: str,
|
||||
content: str,
|
||||
reply_to: str | None,
|
||||
metadata: dict[str, Any] | None,
|
||||
) -> None:
|
||||
async def _send() -> None:
|
||||
await adapter.send(
|
||||
chat_id=chat_id,
|
||||
content=content,
|
||||
reply_to=reply_to,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
future = asyncio.run_coroutine_threadsafe(_send(), loop)
|
||||
|
||||
def _log_failure(done: Any) -> None:
|
||||
try:
|
||||
done.result()
|
||||
except Exception:
|
||||
logger.warning("failed to send Mika platform action reply", exc_info=True)
|
||||
|
||||
future.add_done_callback(_log_failure)
|
||||
|
||||
|
||||
def _run_gateway_platform_action(
|
||||
*,
|
||||
action: str,
|
||||
natural_language_input: str,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
adapter: Any,
|
||||
chat_id: str,
|
||||
reply_to: str | None,
|
||||
metadata: dict[str, Any] | None,
|
||||
) -> None:
|
||||
try:
|
||||
if action == "cronjob":
|
||||
content = handle_cronjob_create({
|
||||
"natural_language_input": natural_language_input,
|
||||
})
|
||||
elif action == "skill":
|
||||
content = handle_skill_create({
|
||||
"natural_language_input": natural_language_input,
|
||||
})
|
||||
else:
|
||||
content = "Erro: ação da plataforma não reconhecida."
|
||||
except Exception as exc:
|
||||
logger.warning("Mika platform action intercept failed", exc_info=True)
|
||||
label = "automação" if action == "cronjob" else "skill"
|
||||
content = f"Erro ao criar {label}: {exc}"
|
||||
|
||||
_schedule_gateway_reply(
|
||||
loop=loop,
|
||||
adapter=adapter,
|
||||
chat_id=chat_id,
|
||||
content=content,
|
||||
reply_to=reply_to,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
|
||||
def handle_gateway_platform_action_intercept(
|
||||
*,
|
||||
event: Any,
|
||||
gateway: Any,
|
||||
session_store: Any = None,
|
||||
) -> dict[str, str] | None:
|
||||
"""Pre-gateway hook that makes Mika platform actions deterministic.
|
||||
|
||||
The LLM can still use cronjob_create/skill_create as tools, but explicit
|
||||
Telegram requests are also routed directly to Supabase before the model
|
||||
runs. That keeps Supabase as the source of truth and avoids a natural
|
||||
language "ok, vou lembrar" response that never persisted anything.
|
||||
"""
|
||||
del session_store
|
||||
|
||||
if not _is_truthy_env_default_true(*GATEWAY_ACTION_INTERCEPT_ENV_NAMES):
|
||||
return None
|
||||
|
||||
if bool(getattr(event, "internal", False)):
|
||||
return None
|
||||
|
||||
source = getattr(event, "source", None)
|
||||
if source is None or bool(getattr(source, "is_bot", False)):
|
||||
return None
|
||||
|
||||
text = str(getattr(event, "text", "") or "").strip()
|
||||
action = detect_gateway_platform_action(text)
|
||||
if not action:
|
||||
return None
|
||||
|
||||
if not _source_is_authorized_for_gateway_intercept(gateway, source):
|
||||
return None
|
||||
|
||||
adapters = getattr(gateway, "adapters", {}) or {}
|
||||
adapter = adapters.get(getattr(source, "platform", None))
|
||||
chat_id = str(getattr(source, "chat_id", "") or "").strip()
|
||||
if adapter is None or not chat_id:
|
||||
logger.warning("Mika intercept could not find adapter/chat_id for action=%s", action)
|
||||
return None
|
||||
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
logger.warning("Mika intercept has no running event loop")
|
||||
return None
|
||||
|
||||
thread = threading.Thread(
|
||||
target=_run_gateway_platform_action,
|
||||
kwargs={
|
||||
"action": action,
|
||||
"natural_language_input": text,
|
||||
"loop": loop,
|
||||
"adapter": adapter,
|
||||
"chat_id": chat_id,
|
||||
"reply_to": _gateway_reply_anchor(gateway, event),
|
||||
"metadata": _gateway_thread_metadata(gateway, event),
|
||||
},
|
||||
name=f"mika-platform-action-{action}",
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
|
||||
return {"action": "skip", "reason": f"mika_{action}_handled"}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue