mirror of
https://github.com/domfelipe/hermes-agent-custom.git
synced 2026-08-07 11:36:48 +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
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