diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 3da9ec6..82d7412 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -21,6 +21,11 @@ jobs: - name: Checkout 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 uses: docker/setup-buildx-action@v3 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a160f49 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ diff --git a/Dockerfile b/Dockerfile index 124c5e9..fc21750 100644 --- a/Dockerfile +++ b/Dockerfile @@ -74,6 +74,7 @@ RUN printf '%s\n' \ '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 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 # Cria o usuário hermes (esperado pelos scripts internos do Hermes) diff --git a/README.md b/README.md index 63a5944..63b8bd0 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ The Hermes gateway ignores environment variables for the primary model configura ## Configuration The image pre-configures: + - **Provider**: `ollama-cloud` - **Model**: `gemma4:31b-cloud` - **Config path**: `/opt/data/.hermes/config.yaml` @@ -28,6 +29,23 @@ PORT=8642 TELEGRAM_BOT_TOKEN=your-bot-token TELEGRAM_ALLOWED_USERS=your-user-id TELEGRAM_HOME_CHANNEL=your-channel-id +MIKA_AGENT_INSTANCE_ID=agent-instance-uuid +MIKA_PLATFORM_FUNCTIONS_BASE_URL=https://.supabase.co/functions/v1 +MIKA_CREATE_CRONJOB_URL=https://.supabase.co/functions/v1/create-cronjob-from-agent +MIKA_CREATE_SKILL_URL=https://.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. + +## Validation + +```bash +python3 -m unittest discover -s tests -v +python3 -m compileall plugins patches tests +git diff --check ``` ## Deploy to Railway diff --git a/plugins/mika_runtime/__init__.py b/plugins/mika_runtime/__init__.py index da23b5b..22ed9a9 100644 --- a/plugins/mika_runtime/__init__.py +++ b/plugins/mika_runtime/__init__.py @@ -5,11 +5,13 @@ from plugins.mika_runtime.tools import ( CRONJOB_CREATE_SCHEMA, INTEGRATIONS_STATUS_SCHEMA, NOTION_API_SCHEMA, + SKILL_CREATE_SCHEMA, TODOIST_API_SCHEMA, handle_calcom_api, handle_cronjob_create, handle_integrations_status, handle_notion_api, + handle_skill_create, handle_todoist_api, ) @@ -51,3 +53,10 @@ def register(ctx) -> None: handler=handle_cronjob_create, emoji="⏰", ) + ctx.register_tool( + name="skill_create", + toolset="mika_integrations", + schema=SKILL_CREATE_SCHEMA, + handler=handle_skill_create, + emoji="🧩", + ) diff --git a/plugins/mika_runtime/plugin.yaml b/plugins/mika_runtime/plugin.yaml index 972643a..5ffdb35 100644 --- a/plugins/mika_runtime/plugin.yaml +++ b/plugins/mika_runtime/plugin.yaml @@ -9,3 +9,4 @@ provides_tools: - mika_todoist_api - mika_calcom_api - cronjob_create + - skill_create diff --git a/plugins/mika_runtime/tools.py b/plugins/mika_runtime/tools.py index ad60cd7..2c619d3 100644 --- a/plugins/mika_runtime/tools.py +++ b/plugins/mika_runtime/tools.py @@ -17,6 +17,16 @@ DEFAULT_NOTION_VERSION = "2022-06-28" DEFAULT_CALCOM_VERSION = "2026-02-25" MAX_RESPONSE_CHARS = 20000 USER_AGENT = "domco-mika-runtime/0.1" +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", +) INTEGRATIONS_STATUS_SCHEMA = { "name": "mika_integrations_status", @@ -535,61 +545,209 @@ 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 diff --git a/tests/test_mika_runtime_tools.py b/tests/test_mika_runtime_tools.py new file mode 100644 index 0000000..db828d4 --- /dev/null +++ b/tests/test_mika_runtime_tools.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import json +import os +import sys +import tempfile +import types +import unittest +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_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) + + +if __name__ == "__main__": + unittest.main()