mirror of
https://github.com/domfelipe/hermes-agent-custom.git
synced 2026-08-07 06:16:39 +00:00
fix: intercept mika platform actions before llm
This commit is contained in:
parent
9e22243c6b
commit
b5bf9dff1b
3 changed files with 382 additions and 0 deletions
|
|
@ -9,6 +9,7 @@ from plugins.mika_runtime.tools import (
|
||||||
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_skill_create,
|
||||||
|
|
@ -60,3 +61,7 @@ def register(ctx) -> None:
|
||||||
handler=handle_skill_create,
|
handler=handle_skill_create,
|
||||||
emoji="🧩",
|
emoji="🧩",
|
||||||
)
|
)
|
||||||
|
ctx.register_hook(
|
||||||
|
"pre_gateway_dispatch",
|
||||||
|
handle_gateway_platform_action_intercept,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,7 @@ 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 = (
|
AGENT_INSTANCE_ENV_NAMES = (
|
||||||
"MIKA_AGENT_INSTANCE_ID",
|
"MIKA_AGENT_INSTANCE_ID",
|
||||||
"HERMES_AGENT_INSTANCE_ID",
|
"HERMES_AGENT_INSTANCE_ID",
|
||||||
|
|
@ -27,6 +33,10 @@ INTERNAL_SECRET_ENV_NAMES = (
|
||||||
"HERMES_INTERNAL_FUNCTION_SECRET",
|
"HERMES_INTERNAL_FUNCTION_SECRET",
|
||||||
"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",
|
||||||
|
|
@ -165,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": []}
|
||||||
|
|
@ -751,3 +844,169 @@ def handle_skill_create(args: dict[str, Any], **_: Any) -> str:
|
||||||
if synced_count is not None:
|
if synced_count is not None:
|
||||||
msg += f" Skills ativas sincronizadas: {synced_count}."
|
msg += f" Skills ativas sincronizadas: {synced_count}."
|
||||||
return msg
|
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"}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import types
|
import types
|
||||||
import unittest
|
import unittest
|
||||||
|
import asyncio
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -34,6 +35,28 @@ class FakeResponse:
|
||||||
|
|
||||||
|
|
||||||
class MikaRuntimePlatformActionTests(unittest.TestCase):
|
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:
|
def test_cronjob_create_posts_platform_contract(self) -> None:
|
||||||
captured = {}
|
captured = {}
|
||||||
|
|
||||||
|
|
@ -135,5 +158,100 @@ class MikaRuntimePlatformActionTests(unittest.TestCase):
|
||||||
self.assertIn("endpoint da plataforma não configurado", result)
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue