From 637cfa79400970762f79fc96d51f409f6373b7a9 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Sun, 23 Aug 2026 11:44:14 +0200 Subject: [PATCH 1/9] refactor(block-h): move AI tool registry into core AI layer --- app/ai/agent_loop.py | 2 +- app/ai/agent_permissions.py | 2 +- app/ai/context_builder.py | 2 +- app/ai/integration_tools.py | 170 ------------------ app/ai/tool_registry.py | 126 +++++++++++++ .../builtins/ai_assistant/tool_registry.py | 128 ++----------- 6 files changed, 142 insertions(+), 288 deletions(-) delete mode 100644 app/ai/integration_tools.py create mode 100644 app/ai/tool_registry.py diff --git a/app/ai/agent_loop.py b/app/ai/agent_loop.py index cfb88d0..e3854c7 100644 --- a/app/ai/agent_loop.py +++ b/app/ai/agent_loop.py @@ -50,7 +50,7 @@ if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncSession - from app.plugins.builtins.ai_assistant.tool_registry import ToolRegistry + from app.ai.tool_registry import ToolRegistry logger = logging.getLogger(__name__) diff --git a/app/ai/agent_permissions.py b/app/ai/agent_permissions.py index 3ce0736..62cfb31 100644 --- a/app/ai/agent_permissions.py +++ b/app/ai/agent_permissions.py @@ -61,7 +61,7 @@ def _resolve_effective_tool_ids( orchestrate tools but never grant additional permissions. """ from app.ai.skill_registry import get_skill_registry - from app.plugins.builtins.ai_assistant.contracts import get_tool_registry + from app.ai.tool_registry import get_tool_registry agent_tool_ids: list[str] = list(getattr(agent_definition, "tool_ids", None) or []) agent_skill_ids: list[str] = list(getattr(agent_definition, "skill_ids", None) or []) diff --git a/app/ai/context_builder.py b/app/ai/context_builder.py index cca3577..2ba80ff 100644 --- a/app/ai/context_builder.py +++ b/app/ai/context_builder.py @@ -221,7 +221,7 @@ async def build_agent_context( tool_descriptions: list[dict[str, str]] = [] tool_ids = list(getattr(agent_definition, "tool_ids", None) or []) try: - from app.plugins.builtins.ai_assistant.contracts import get_tool_registry + from app.ai.tool_registry import get_tool_registry registry = get_tool_registry() if tool_ids: diff --git a/app/ai/integration_tools.py b/app/ai/integration_tools.py deleted file mode 100644 index 4849f85..0000000 --- a/app/ai/integration_tools.py +++ /dev/null @@ -1,170 +0,0 @@ -"""Integration tools — Agent → Workflow + Agent → Knowledge. - -Registers AI tools that allow agents to start workflows and ask knowledge questions. -Builds on: workflow_service, knowledge services, tool_registry. -""" -from __future__ import annotations -import logging -import uuid -from typing import Any - -logger = logging.getLogger(__name__) - - -def register_integration_tools() -> None: - """Register workflow + knowledge tools in the AI tool registry.""" - from app.plugins.builtins.ai_assistant.tool_registry import get_tool_registry - registry = get_tool_registry() - - # ── I-AW: Agent → Workflow ── - async def _start_workflow_handler(arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]: - """Start a workflow by ID.""" - from app.services.workflow_service import create_instance - from app.core.db import get_worker_session_factory - workflow_id = arguments.get("workflow_id", "") - tenant_id = context.get("tenant_id") - user_id = context.get("user_id") - if not workflow_id or not tenant_id: - return {"error": "workflow_id and tenant_id required"} - factory = get_worker_session_factory() - async with factory() as db: - instance = await create_instance( - db=db, - tenant_id=uuid.UUID(str(tenant_id)), - workflow_id=uuid.UUID(workflow_id), - initiated_by=uuid.UUID(str(user_id)) if user_id else None, - ) - await db.commit() - return {"instance_id": str(instance.get("id", "")), "status": instance.get("status", "created")} - - registry.register( - name="start_workflow", - description="Start a workflow by its ID. Returns the instance ID and status.", - parameters={ - "type": "object", - "properties": { - "workflow_id": {"type": "string", "description": "UUID of the workflow to start"}, - }, - "required": ["workflow_id"], - }, - handler=_start_workflow_handler, - plugin_name="integration", - required_permission="workflows:read", - category="workflow", - ) - - async def _check_workflow_status_handler(arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]: - """Check the status of a workflow instance.""" - from sqlalchemy import select - from app.models.workflow import WorkflowInstance - from app.core.db import get_worker_session_factory - instance_id = arguments.get("instance_id", "") - tenant_id = context.get("tenant_id") - if not instance_id or not tenant_id: - return {"error": "instance_id and tenant_id required"} - factory = get_worker_session_factory() - async with factory() as db: - result = await db.execute( - select(WorkflowInstance).where( - WorkflowInstance.id == uuid.UUID(instance_id), - WorkflowInstance.tenant_id == uuid.UUID(str(tenant_id)), - ) - ) - inst = result.scalar_one_or_none() - if not inst: - return {"error": "Instance not found"} - return { - "instance_id": str(inst.id), - "status": inst.status, - "current_step": inst.current_step_index, - "completed_at": inst.completed_at.isoformat() if inst.completed_at else None, - } - - registry.register( - name="check_workflow_status", - description="Check the status of a workflow instance by its ID.", - parameters={ - "type": "object", - "properties": { - "instance_id": {"type": "string", "description": "UUID of the workflow instance"}, - }, - "required": ["instance_id"], - }, - handler=_check_workflow_status_handler, - plugin_name="integration", - required_permission="workflows:read", - category="workflow", - ) - - # ── I-AK: Agent → Knowledge ── - async def _ask_knowledge_handler(arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]: - """Ask a knowledge question.""" - from app.plugins.builtins.knowledge.services import ask_knowledge - from app.core.db import get_worker_session_factory - question = arguments.get("question", "") - tenant_id = context.get("tenant_id") - if not question or not tenant_id: - return {"error": "question and tenant_id required"} - factory = get_worker_session_factory() - async with factory() as db: - result = await ask_knowledge(db=db, tenant_id=uuid.UUID(str(tenant_id)), question=question) - return {"answer": result.get("answer", ""), "evidence_count": len(result.get("evidence", []))} - - registry.register( - name="ask_knowledge", - description="Ask a knowledge question. Searches wiki articles and graph relationships for context.", - parameters={ - "type": "object", - "properties": { - "question": {"type": "string", "description": "The question to ask"}, - }, - "required": ["question"], - }, - handler=_ask_knowledge_handler, - plugin_name="integration", - required_permission="wiki:read", - category="knowledge", - ) - - async def _search_knowledge_handler(arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]: - """Search wiki articles via unified search.""" - from app.plugins.builtins.unified_search.provider_registry import get_search_registry - from app.core.db import get_worker_session_factory - query = arguments.get("query", "") - tenant_id = context.get("tenant_id") - if not query or not tenant_id: - return {"error": "query and tenant_id required"} - factory = get_worker_session_factory() - async with factory() as db: - registry = get_search_registry() - wiki_provider = registry.get("wiki_article") - if not wiki_provider: - return {"error": "Wiki search provider not available"} - results = await wiki_provider._search_fts_filtered( - db=db, tsquery=query, tenant_id=uuid.UUID(str(tenant_id)), limit=5, visible_ids=None - ) - return { - "results": [ - {"title": r.get("title", ""), "summary": (r.get("summary") or r.get("content", "")[:200] or "")} - for r in results - ], - "count": len(results), - } - - registry.register( - name="search_knowledge", - description="Search wiki articles by keyword. Returns matching articles with title and summary.", - parameters={ - "type": "object", - "properties": { - "query": {"type": "string", "description": "Search query"}, - }, - "required": ["query"], - }, - handler=_search_knowledge_handler, - plugin_name="integration", - required_permission="wiki:read", - category="knowledge", - ) - - logger.info("Registered integration tools: start_workflow, check_workflow_status, ask_knowledge, search_knowledge") diff --git a/app/ai/tool_registry.py b/app/ai/tool_registry.py new file mode 100644 index 0000000..a83efc3 --- /dev/null +++ b/app/ai/tool_registry.py @@ -0,0 +1,126 @@ +"""Global tool registry for AI agent tools (core platform service). + +Plugins register tools here so AI agents can call them during chat sessions. +Each tool declares a name, description, JSON schema for parameters, +and an async handler. Tools can optionally require specific RBAC permissions. + +This registry lives in the core AI layer (not inside a plugin) so that the +agent runtime keeps working regardless of which optional plugins are active. +Plugins contribute tools via ``register()`` / ``unregister_plugin()`` during +their activate/deactivate lifecycle. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Any, Protocol + +logger = logging.getLogger(__name__) + + +class ToolHandler(Protocol): + async def __call__( + self, + arguments: dict[str, Any], + context: dict[str, Any], + ) -> str: ... + + +@dataclass +class AITool: + """Represents a tool that an AI agent can call.""" + + name: str + description: str + parameters: dict[str, Any] # JSON Schema for parameters + handler: ToolHandler + plugin_name: str = "" + required_permission: str | None = None # e.g. "mail:send" + category: str = "general" + + def to_openai_schema(self) -> dict[str, Any]: + """Convert to OpenAI function-calling tool schema.""" + return { + "type": "function", + "function": { + "name": self.name, + "description": self.description, + "parameters": self.parameters, + }, + } + + +class ToolRegistry: + """Singleton registry for AI tools.""" + + _instance: ToolRegistry | None = None + + def __new__(cls) -> ToolRegistry: + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._tools: dict[str, AITool] = {} + return cls._instance + + def register( + self, + name: str, + description: str, + parameters: dict[str, Any], + handler: ToolHandler, + plugin_name: str = "", + required_permission: str | None = None, + category: str = "general", + ) -> None: + """Register a tool.""" + tool = AITool( + name=name, + description=description, + parameters=parameters, + handler=handler, + plugin_name=plugin_name, + required_permission=required_permission, + category=category, + ) + self._tools[name] = tool + logger.info("AI tool registered: %s (plugin=%s)", name, plugin_name) + + def unregister(self, name: str) -> None: + """Unregister a tool by name.""" + self._tools.pop(name, None) + + def unregister_plugin(self, plugin_name: str) -> None: + """Unregister all tools from a plugin.""" + to_remove = [ + name for name, tool in self._tools.items() if tool.plugin_name == plugin_name + ] + for name in to_remove: + self._tools.pop(name, None) + + def get(self, name: str) -> AITool | None: + return self._tools.get(name) + + def get_all(self) -> list[AITool]: + return list(self._tools.values()) + + def get_by_names(self, names: list[str]) -> list[AITool]: + return [self._tools[name] for name in names if name in self._tools] + + def list_for_api(self) -> list[dict[str, Any]]: + """Return tool list for API response.""" + return [ + { + "name": tool.name, + "description": tool.description, + "parameters": tool.parameters, + "plugin_name": tool.plugin_name, + "required_permission": tool.required_permission, + "category": tool.category, + } + for tool in self._tools.values() + ] + + +def get_tool_registry() -> ToolRegistry: + """Get the global tool registry singleton.""" + return ToolRegistry() diff --git a/app/plugins/builtins/ai_assistant/tool_registry.py b/app/plugins/builtins/ai_assistant/tool_registry.py index d515236..507482f 100644 --- a/app/plugins/builtins/ai_assistant/tool_registry.py +++ b/app/plugins/builtins/ai_assistant/tool_registry.py @@ -1,121 +1,19 @@ -"""Global tool registry for AI Assistant plugin tools. +"""Compatibility shim — the tool registry moved to the core AI layer. -Plugins can register tools that AI agents can call during chat sessions. -Each tool declares a name, description, JSON schema for parameters, -and an async handler. Tools can optionally require specific RBAC permissions. +The agent runtime must not depend on this optional plugin being active, +so ``ToolRegistry`` / ``AITool`` / ``get_tool_registry`` now live in +``app.ai.tool_registry``. Import from here still works for existing +plugin code; new code should import from ``app.ai.tool_registry`` +directly (or via the ai_assistant contract). """ from __future__ import annotations -import logging -from dataclasses import dataclass -from typing import Any, Protocol +from app.ai.tool_registry import ( # noqa: F401 + AITool, + ToolHandler, + ToolRegistry, + get_tool_registry, +) -logger = logging.getLogger(__name__) - - -class ToolHandler(Protocol): - async def __call__( - self, - arguments: dict[str, Any], - context: dict[str, Any], - ) -> str: ... - - -@dataclass -class AITool: - """Represents a tool that an AI agent can call.""" - - name: str - description: str - parameters: dict[str, Any] # JSON Schema for parameters - handler: ToolHandler - plugin_name: str = "" - required_permission: str | None = None # e.g. "mail:send" - category: str = "general" - - def to_openai_schema(self) -> dict[str, Any]: - """Convert to OpenAI function-calling tool schema.""" - return { - "type": "function", - "function": { - "name": self.name, - "description": self.description, - "parameters": self.parameters, - }, - } - - -class ToolRegistry: - """Singleton registry for AI tools.""" - - _instance: ToolRegistry | None = None - - def __new__(cls) -> ToolRegistry: - if cls._instance is None: - cls._instance = super().__new__(cls) - cls._instance._tools: dict[str, AITool] = {} - return cls._instance - - def register( - self, - name: str, - description: str, - parameters: dict[str, Any], - handler: ToolHandler, - plugin_name: str = "", - required_permission: str | None = None, - category: str = "general", - ) -> None: - """Register a tool.""" - tool = AITool( - name=name, - description=description, - parameters=parameters, - handler=handler, - plugin_name=plugin_name, - required_permission=required_permission, - category=category, - ) - self._tools[name] = tool - logger.info("AI tool registered: %s (plugin=%s)", name, plugin_name) - - def unregister(self, name: str) -> None: - """Unregister a tool by name.""" - self._tools.pop(name, None) - - def unregister_plugin(self, plugin_name: str) -> None: - """Unregister all tools from a plugin.""" - to_remove = [ - name for name, tool in self._tools.items() if tool.plugin_name == plugin_name - ] - for name in to_remove: - self._tools.pop(name, None) - - def get(self, name: str) -> AITool | None: - return self._tools.get(name) - - def get_all(self) -> list[AITool]: - return list(self._tools.values()) - - def get_by_names(self, names: list[str]) -> list[AITool]: - return [self._tools[name] for name in names if name in self._tools] - - def list_for_api(self) -> list[dict[str, Any]]: - """Return tool list for API response.""" - return [ - { - "name": tool.name, - "description": tool.description, - "parameters": tool.parameters, - "plugin_name": tool.plugin_name, - "required_permission": tool.required_permission, - "category": tool.category, - } - for tool in self._tools.values() - ] - - -def get_tool_registry() -> ToolRegistry: - """Get the global tool registry singleton.""" - return ToolRegistry() +__all__ = ["AITool", "ToolHandler", "ToolRegistry", "get_tool_registry"] From 1f4a6219104e27d8be71e4458cd3cb958ab9db35 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Sun, 23 Aug 2026 11:44:14 +0200 Subject: [PATCH 2/9] refactor(block-h): own integration agent tools in their plugins --- app/plugins/builtins/automation/plugin.py | 102 +++++++++++++++++++++- app/plugins/builtins/knowledge/plugin.py | 90 +++++++++++++++++++ 2 files changed, 188 insertions(+), 4 deletions(-) diff --git a/app/plugins/builtins/automation/plugin.py b/app/plugins/builtins/automation/plugin.py index ee7a56f..10114b8 100644 --- a/app/plugins/builtins/automation/plugin.py +++ b/app/plugins/builtins/automation/plugin.py @@ -210,12 +210,11 @@ class AutomationPlugin(BasePlugin): register_agent_coordinator_tools() except Exception: logger.exception("Failed to register agent coordinator tools") - # Register integration tools (I-AW: Agent→Workflow, I-AK: Agent→Knowledge) + # Register workflow agent tools (I-AW: Agent→Workflow) try: - from app.ai.integration_tools import register_integration_tools - register_integration_tools() + self._register_workflow_agent_tools() except Exception: - logger.exception("Failed to register integration tools") + logger.exception("Failed to register workflow agent tools") # Register MiniApps from manifest try: from app.plugins.builtins.kommunikation.contracts import get_miniapp_registry @@ -286,6 +285,94 @@ class AutomationPlugin(BasePlugin): logger.info("Automation plugin activated") + def _register_workflow_agent_tools(self) -> None: + """Register I-AW agent tools for starting and inspecting workflows.""" + import uuid + from typing import Any + + from app.ai.tool_registry import get_tool_registry + registry = get_tool_registry() + + async def _start_workflow_handler(arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]: + """Start a workflow by ID.""" + from app.services.workflow_service import create_instance + from app.core.db import get_worker_session_factory + workflow_id = arguments.get("workflow_id", "") + tenant_id = context.get("tenant_id") + user_id = context.get("user_id") + if not workflow_id or not tenant_id: + return {"error": "workflow_id and tenant_id required"} + factory = get_worker_session_factory() + async with factory() as db: + instance = await create_instance( + db=db, + tenant_id=uuid.UUID(str(tenant_id)), + workflow_id=uuid.UUID(workflow_id), + initiated_by=uuid.UUID(str(user_id)) if user_id else None, + ) + await db.commit() + return {"instance_id": str(instance.get("id", "")), "status": instance.get("status", "created")} + + registry.register( + name="start_workflow", + description="Start a workflow by its ID. Returns the instance ID and status.", + parameters={ + "type": "object", + "properties": { + "workflow_id": {"type": "string", "description": "UUID of the workflow to start"}, + }, + "required": ["workflow_id"], + }, + handler=_start_workflow_handler, + plugin_name=self.manifest.name, + required_permission="workflows:read", + category="workflow", + ) + + async def _check_workflow_status_handler(arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]: + """Check the status of a workflow instance.""" + from sqlalchemy import select + from app.models.workflow import WorkflowInstance + from app.core.db import get_worker_session_factory + instance_id = arguments.get("instance_id", "") + tenant_id = context.get("tenant_id") + if not instance_id or not tenant_id: + return {"error": "instance_id and tenant_id required"} + factory = get_worker_session_factory() + async with factory() as db: + result = await db.execute( + select(WorkflowInstance).where( + WorkflowInstance.id == uuid.UUID(instance_id), + WorkflowInstance.tenant_id == uuid.UUID(str(tenant_id)), + ) + ) + inst = result.scalar_one_or_none() + if not inst: + return {"error": "Instance not found"} + return { + "instance_id": str(inst.id), + "status": inst.status, + "current_step": inst.current_step_index, + "completed_at": inst.completed_at.isoformat() if inst.completed_at else None, + } + + registry.register( + name="check_workflow_status", + description="Check the status of a workflow instance by its ID.", + parameters={ + "type": "object", + "properties": { + "instance_id": {"type": "string", "description": "UUID of the workflow instance"}, + }, + "required": ["instance_id"], + }, + handler=_check_workflow_status_handler, + plugin_name=self.manifest.name, + required_permission="workflows:read", + category="workflow", + ) + logger.info("Registered workflow agent tools: start_workflow, check_workflow_status") + async def on_deactivate(self, db, service_container, event_bus) -> None: """Clean up on deactivation.""" # Contract abmelden @@ -307,6 +394,13 @@ class AutomationPlugin(BasePlugin): unregister_agent_coordinator_tools() except Exception: logger.exception("Failed to unregister agent coordinator tools") + # Unregister workflow agent tools from the core AI tool registry + try: + from app.ai.tool_registry import get_tool_registry + get_tool_registry().unregister_plugin(self.manifest.name) + logger.info("Unregistered AI agent tools for plugin '%s'", self.manifest.name) + except Exception: + logger.exception("Failed to unregister AI agent tools") # Unregister MiniApps try: from app.plugins.builtins.kommunikation.contracts import get_miniapp_registry diff --git a/app/plugins/builtins/knowledge/plugin.py b/app/plugins/builtins/knowledge/plugin.py index 9d02001..a5acd07 100644 --- a/app/plugins/builtins/knowledge/plugin.py +++ b/app/plugins/builtins/knowledge/plugin.py @@ -1,6 +1,8 @@ """Knowledge plugin — LLM-based entity/relationship extraction, ask-knowledge, review queue.""" from __future__ import annotations import logging +import uuid +from typing import Any from app.plugins.base import BasePlugin from app.plugins.manifest import PluginManifest, PluginRouteDef @@ -59,9 +61,97 @@ class KnowledgePlugin(BasePlugin): logger.info("Registered knowledge extraction hooks") except Exception: logger.exception("Failed to register knowledge hooks") + # Register knowledge agent tools (I-AK: Agent→Knowledge) + try: + self._register_knowledge_agent_tools() + except Exception: + logger.exception("Failed to register knowledge agent tools") + + def _register_knowledge_agent_tools(self) -> None: + """Register I-AK agent tools for asking and searching knowledge.""" + from app.ai.tool_registry import get_tool_registry + registry = get_tool_registry() + + async def _ask_knowledge_handler(arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]: + """Ask a knowledge question.""" + from app.plugins.builtins.knowledge.services import ask_knowledge + from app.core.db import get_worker_session_factory + question = arguments.get("question", "") + tenant_id = context.get("tenant_id") + if not question or not tenant_id: + return {"error": "question and tenant_id required"} + factory = get_worker_session_factory() + async with factory() as db: + result = await ask_knowledge(db=db, tenant_id=uuid.UUID(str(tenant_id)), question=question) + return {"answer": result.get("answer", ""), "evidence_count": len(result.get("evidence", []))} + + registry.register( + name="ask_knowledge", + description="Ask a knowledge question. Searches wiki articles and graph relationships for context.", + parameters={ + "type": "object", + "properties": { + "question": {"type": "string", "description": "The question to ask"}, + }, + "required": ["question"], + }, + handler=_ask_knowledge_handler, + plugin_name=self.manifest.name, + required_permission="wiki:read", + category="knowledge", + ) + + async def _search_knowledge_handler(arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]: + """Search wiki articles via unified search.""" + from app.plugins.builtins.unified_search.provider_registry import get_search_registry + from app.core.db import get_worker_session_factory + query = arguments.get("query", "") + tenant_id = context.get("tenant_id") + if not query or not tenant_id: + return {"error": "query and tenant_id required"} + factory = get_worker_session_factory() + async with factory() as db: + search_registry = get_search_registry() + wiki_provider = search_registry.get("wiki_article") + if not wiki_provider: + return {"error": "Wiki search provider not available"} + results = await wiki_provider._search_fts_filtered( + db=db, tsquery=query, tenant_id=uuid.UUID(str(tenant_id)), limit=5, visible_ids=None + ) + return { + "results": [ + {"title": r.get("title", ""), "summary": (r.get("summary") or r.get("content", "")[:200] or "")} + for r in results + ], + "count": len(results), + } + + registry.register( + name="search_knowledge", + description="Search wiki articles by keyword. Returns matching articles with title and summary.", + parameters={ + "type": "object", + "properties": { + "query": {"type": "string", "description": "Search query"}, + }, + "required": ["query"], + }, + handler=_search_knowledge_handler, + plugin_name=self.manifest.name, + required_permission="wiki:read", + category="knowledge", + ) + logger.info("Registered knowledge agent tools: ask_knowledge, search_knowledge") async def on_deactivate(self, db, service_container, event_bus) -> None: """Clean up on deactivation.""" from app.core.hooks import unregister_actions_by_owner unregister_actions_by_owner("knowledge") + # Unregister knowledge agent tools from the core AI tool registry + try: + from app.ai.tool_registry import get_tool_registry + get_tool_registry().unregister_plugin(self.manifest.name) + logger.info("Unregistered AI agent tools for plugin '%s'", self.manifest.name) + except Exception: + logger.exception("Failed to unregister AI agent tools") await super().on_deactivate(db, service_container, event_bus) From a7699d3598bd4901d20bafb84750db7d81a390d9 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Sun, 23 Aug 2026 12:02:29 +0200 Subject: [PATCH 3/9] refactor(block-h): resolve CommConversation hardcoding via kommunikation contract --- app/ai/agent_loop.py | 20 ++++++---------- .../builtins/kommunikation/contracts.py | 3 +++ .../builtins/kommunikation/services.py | 24 +++++++++++++++++++ app/workflows/engine.py | 20 +++++----------- 4 files changed, 40 insertions(+), 27 deletions(-) diff --git a/app/ai/agent_loop.py b/app/ai/agent_loop.py index e3854c7..f376e94 100644 --- a/app/ai/agent_loop.py +++ b/app/ai/agent_loop.py @@ -394,27 +394,21 @@ async def run_react_loop( if agent_run_id: try: from app.plugins.builtins.contracts import get_contract_registry - from app.plugins.builtins.kommunikation.models import CommConversation - from sqlalchemy import select as sa_select komm = get_contract_registry().get("kommunikation") if komm: agent_id = getattr(agent_definition, "id", uuid.uuid4()) room_title = f"Agent: {getattr(agent_definition, 'name', 'Agent')}" - existing = await db.execute( - sa_select(CommConversation).where( - CommConversation.tenant_id == tenant_id, - CommConversation.title == room_title, - CommConversation.is_locked.is_(True), - CommConversation.locked_by == "automation", - CommConversation.deleted_at.is_(None), - ) + conv_id = await komm.find_locked_room_id( + db=db, + tenant_id=tenant_id, + plugin_name="automation", + title=room_title, ) - conv = existing.scalar_one_or_none() - if conv: + if conv_id: await komm.send_message( db=db, tenant_id=tenant_id, - conversation_id=conv.id, + conversation_id=conv_id, sender_id=agent_id, sender_type="agent", content=f"Approval required for tool '{tool_name}'", diff --git a/app/plugins/builtins/kommunikation/contracts.py b/app/plugins/builtins/kommunikation/contracts.py index 1d9e2ec..b94cd08 100644 --- a/app/plugins/builtins/kommunikation/contracts.py +++ b/app/plugins/builtins/kommunikation/contracts.py @@ -31,6 +31,7 @@ from app.plugins.builtins.kommunikation.participant_registry import ( ) from app.plugins.builtins.kommunikation.services import ( create_plugin_room, + find_locked_room_id, get_conversation, get_messages, parse_mentions, @@ -55,6 +56,7 @@ class KommunikationContract: get_messages = staticmethod(get_messages) send_message = staticmethod(send_message) create_plugin_room = staticmethod(create_plugin_room) + find_locked_room_id = staticmethod(find_locked_room_id) # ─── participant registry ─── get_participant_registry = staticmethod(get_participant_registry) @@ -92,6 +94,7 @@ __all__ = [ "get_messages", "send_message", "create_plugin_room", + "find_locked_room_id", "CommConversation", "CommMessage", "CommParticipant", diff --git a/app/plugins/builtins/kommunikation/services.py b/app/plugins/builtins/kommunikation/services.py index c32fe0c..ffe0b93 100644 --- a/app/plugins/builtins/kommunikation/services.py +++ b/app/plugins/builtins/kommunikation/services.py @@ -1047,6 +1047,30 @@ async def _get_unread_count( # ─── Plugin Room Creation ─── +async def find_locked_room_id( + db: AsyncSession, + tenant_id: uuid.UUID, + plugin_name: str, + title: str, +) -> uuid.UUID | None: + """Find the conversation ID of a locked plugin room by tenant and title. + + Matches the same room semantics as ``create_plugin_room``: locked rooms + are owned by the plugin (``locked_by == plugin_name``) and soft-deleted + conversations are excluded. Returns ``None`` when no room exists. + """ + result = await db.execute( + select(CommConversation.id).where( + CommConversation.tenant_id == tenant_id, + CommConversation.title == title, + CommConversation.is_locked.is_(True), + CommConversation.locked_by == plugin_name, + CommConversation.deleted_at.is_(None), + ) + ) + return result.scalar_one_or_none() + + async def create_plugin_room( db: AsyncSession, tenant_id: uuid.UUID, diff --git a/app/workflows/engine.py b/app/workflows/engine.py index 29b5175..e9e088b 100644 --- a/app/workflows/engine.py +++ b/app/workflows/engine.py @@ -92,22 +92,16 @@ class WorkflowEngine: # Post workflow completion to Communication (G-WORK) try: from app.plugins.builtins.contracts import get_contract_registry - from app.plugins.builtins.kommunikation.models import CommConversation - from sqlalchemy import select as sa_select komm = get_contract_registry().get("kommunikation") if komm and instance.initiated_by: room_title = f"Workflow: {workflow.name if hasattr(workflow, 'name') else str(instance.workflow_id)}" - existing = await self.db.execute( - sa_select(CommConversation).where( - CommConversation.tenant_id == self.tenant_id, - CommConversation.title == room_title, - CommConversation.is_locked.is_(True), - CommConversation.locked_by == "workflow", - CommConversation.deleted_at.is_(None), - ) + conv_id = await komm.find_locked_room_id( + db=self.db, + tenant_id=self.tenant_id, + plugin_name="workflow", + title=room_title, ) - conv = existing.scalar_one_or_none() - if not conv: + if not conv_id: room = await komm.create_plugin_room( db=self.db, tenant_id=self.tenant_id, @@ -117,8 +111,6 @@ class WorkflowEngine: participant_type="workflow", ) conv_id = uuid.UUID(room["conversation_id"]) - else: - conv_id = conv.id await komm.send_message( db=self.db, tenant_id=self.tenant_id, From 44511a8fd79e0757ec603689e0c14b2c94a82a7b Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Sun, 23 Aug 2026 12:18:02 +0200 Subject: [PATCH 4/9] refactor(block-h): knowledge retention job lives with plugin; compliance via contract --- app/core/job_registry.py | 9 ++++ app/core/worker.py | 53 +++----------------- app/plugins/builtins/knowledge/jobs.py | 62 ++++++++++++++++++++++++ app/plugins/builtins/knowledge/plugin.py | 4 ++ app/routes/compliance.py | 36 ++++++++++---- 5 files changed, 108 insertions(+), 56 deletions(-) create mode 100644 app/plugins/builtins/knowledge/jobs.py diff --git a/app/core/job_registry.py b/app/core/job_registry.py index ebd2e30..b94baf1 100644 --- a/app/core/job_registry.py +++ b/app/core/job_registry.py @@ -46,6 +46,15 @@ def get_job(name: str) -> JobFunc | None: return _registry.get(name) +def unregister_job(name: str) -> None: + """Remove a registered job function (plugin deactivation lifecycle). + + Args: + name: The job name to remove. + """ + _registry.pop(name, None) + + def get_all_jobs() -> list[JobFunc]: """Return all registered job functions (order is insertion order). diff --git a/app/core/worker.py b/app/core/worker.py index 9130d7e..5199a48 100644 --- a/app/core/worker.py +++ b/app/core/worker.py @@ -445,51 +445,9 @@ async def cleanup_trash_job(ctx: dict[str, Any]) -> None: register_job("cleanup_trash", cleanup_trash_job) -# ── Knowledge retention cleanup job ───────────────────────────────────────── - -async def cleanup_knowledge_job(ctx: dict[str, Any]) -> None: - """Delete old knowledge extractions (rejected or auto_created) older than 90 days. - - Runs daily. Keeps approved extractions indefinitely. - Iterates per-tenant for RLS compliance. - """ - from sqlalchemy import text as sa_text, delete as sa_delete - from datetime import datetime, timedelta - - from app.core.db import get_worker_session_factory - from app.plugins.builtins.knowledge.models import KnowledgeExtraction - - factory = get_worker_session_factory() - async with factory() as db: - try: - tenant_result = await db.execute(sa_text("SELECT id FROM tenants")) - tenant_ids = [row[0] for row in tenant_result] - - cutoff = datetime.utcnow() - timedelta(days=90) - total_deleted = 0 - for tenant_id in tenant_ids: - await db.execute( - sa_text("SELECT set_config('app.current_tenant_id', :tid, true)"), - {"tid": str(tenant_id)}, - ) - # Delete rejected and auto_created extractions older than 90 days - result = await db.execute( - sa_delete(KnowledgeExtraction).where( - KnowledgeExtraction.status.in_(["rejected", "auto_created"]), - KnowledgeExtraction.created_at < cutoff, - ) - ) - total_deleted += result.rowcount - await db.commit() - - if total_deleted: - logger.info("Knowledge retention: cleaned up %d old extractions", total_deleted) - except Exception: - logger.error("Knowledge retention cleanup failed", exc_info=True) - await db.rollback() - - -register_job("cleanup_knowledge", cleanup_knowledge_job) +# Note: knowledge retention cleanup ("cleanup_knowledge") lives with the +# knowledge plugin (app/plugins/builtins/knowledge/jobs.py) and is discovered +# via the plugin job-module mechanism — no core→plugin import. class WorkerSettings: @@ -531,9 +489,10 @@ class WorkerSettings: _wrap_cron_with_lock("cleanup_trash", cleanup_trash_job, ttl_seconds=300), hour=4, minute=0, ), - # Knowledge retention cleanup — daily at 05:00 (90 days, keeps approved) + # Knowledge retention cleanup — daily at 05:00 (90 days, keeps approved). + # Function comes from the knowledge plugin via the job registry. cron( - _wrap_cron_with_lock("cleanup_knowledge", cleanup_knowledge_job, ttl_seconds=300), + _wrap_cron_with_lock("cleanup_knowledge", get_job("cleanup_knowledge"), ttl_seconds=300), hour=5, minute=0, ), # Scheduled backup — daily at 02:00 (guarded by distributed lock) diff --git a/app/plugins/builtins/knowledge/jobs.py b/app/plugins/builtins/knowledge/jobs.py new file mode 100644 index 0000000..04a8d06 --- /dev/null +++ b/app/plugins/builtins/knowledge/jobs.py @@ -0,0 +1,62 @@ +"""ARQ background jobs for the knowledge plugin. + +Registered via ``register_job()`` at import time; the worker discovers this +module through ``KnowledgePlugin.get_job_modules()`` — no core imports of +plugin models needed. +""" + +from __future__ import annotations + +import logging +from datetime import UTC, datetime, timedelta +from typing import Any + +from app.core.job_registry import register_job +from app.plugins.builtins.knowledge.models import KnowledgeExtraction + +logger = logging.getLogger(__name__) + +_KNOWLEDGE_RETENTION_DAYS = 90 + + +async def cleanup_knowledge_job(ctx: dict[str, Any]) -> None: + """Delete old knowledge extractions (rejected or auto_created) older than 90 days. + + Runs daily. Keeps approved extractions indefinitely. + Iterates per-tenant for RLS compliance. + """ + from sqlalchemy import text as sa_text, delete as sa_delete + + from app.core.db import get_worker_session_factory + + factory = get_worker_session_factory() + async with factory() as db: + try: + tenant_result = await db.execute(sa_text("SELECT id FROM tenants")) + tenant_ids = [row[0] for row in tenant_result] + + cutoff = datetime.now(UTC).replace(tzinfo=None) - timedelta(days=_KNOWLEDGE_RETENTION_DAYS) + total_deleted = 0 + for tenant_id in tenant_ids: + await db.execute( + sa_text("SELECT set_config('app.current_tenant_id', :tid, true)"), + {"tid": str(tenant_id)}, + ) + # Delete rejected and auto_created extractions older than retention window + result = await db.execute( + sa_delete(KnowledgeExtraction).where( + KnowledgeExtraction.status.in_(["rejected", "auto_created"]), + KnowledgeExtraction.created_at < cutoff, + ) + ) + total_deleted += result.rowcount + await db.commit() + + if total_deleted: + logger.info("Knowledge retention: cleaned up %d old extractions", total_deleted) + except Exception: + logger.error("Knowledge retention cleanup failed", exc_info=True) + await db.rollback() + + +register_job("cleanup_knowledge", cleanup_knowledge_job) diff --git a/app/plugins/builtins/knowledge/plugin.py b/app/plugins/builtins/knowledge/plugin.py index a5acd07..43fea23 100644 --- a/app/plugins/builtins/knowledge/plugin.py +++ b/app/plugins/builtins/knowledge/plugin.py @@ -143,6 +143,10 @@ class KnowledgePlugin(BasePlugin): ) logger.info("Registered knowledge agent tools: ask_knowledge, search_knowledge") + def get_job_modules(self) -> list[str]: + """ARQ job modules — the retention cleanup job lives with this plugin.""" + return ["app.plugins.builtins.knowledge.jobs"] + async def on_deactivate(self, db, service_container, event_bus) -> None: """Clean up on deactivation.""" from app.core.hooks import unregister_actions_by_owner diff --git a/app/routes/compliance.py b/app/routes/compliance.py index c5d0875..9eef822 100644 --- a/app/routes/compliance.py +++ b/app/routes/compliance.py @@ -19,11 +19,21 @@ from app.core.db import get_db from app.deps import require_permission from app.models.audit import AuditLog from app.models.compliance import ComplianceIncident -from app.plugins.builtins.automation.models import AgentDefinition +from app.plugins.builtins.contracts import get_contract as _get_automation_contract router = APIRouter(prefix="/api/v1/compliance", tags=["compliance"]) +def _get_agent_definition_model(): + """Resolve the AgentDefinition model via the automation contract. + + Returns ``None`` when the automation plugin is not active — callers + respond with 503 instead of failing at import time. + """ + contract = _get_automation_contract("automation") + return getattr(contract, "AgentDefinition", None) if contract else None + + # ─── Schemas ─── @@ -160,13 +170,17 @@ async def list_ai_registry( """List all AI agents with their use-case metadata. Admin only.""" tenant_id = uuid.UUID(current_user["tenant_id"]) + agent_model = _get_agent_definition_model() + if agent_model is None: + raise HTTPException(503, detail={"detail": "Automation plugin not active", "code": "plugin_inactive"}) + q = ( - select(AgentDefinition) + select(agent_model) .where( - AgentDefinition.tenant_id == tenant_id, - AgentDefinition.deleted_at.is_(None), + agent_model.tenant_id == tenant_id, + agent_model.deleted_at.is_(None), ) - .order_by(AgentDefinition.name) + .order_by(agent_model.name) ) result = await db.execute(q) agents = result.scalars().all() @@ -214,10 +228,14 @@ async def get_dpia_template( except ValueError: raise HTTPException(400, detail={"detail": "Invalid agent_id", "code": "invalid_id"}) from None - q = select(AgentDefinition).where( - AgentDefinition.id == aid, - AgentDefinition.tenant_id == tenant_id, - AgentDefinition.deleted_at.is_(None), + agent_model = _get_agent_definition_model() + if agent_model is None: + raise HTTPException(503, detail={"detail": "Automation plugin not active", "code": "plugin_inactive"}) + + q = select(agent_model).where( + agent_model.id == aid, + agent_model.tenant_id == tenant_id, + agent_model.deleted_at.is_(None), ) result = await db.execute(q) agent = result.scalar_one_or_none() From d87fc4e55c42d48aa17a15b722afe1ab4e86ef36 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Sun, 23 Aug 2026 12:50:53 +0200 Subject: [PATCH 5/9] fix(arch-030,arch-047): workflow steps resolve plugins via contracts at runtime --- app/plugins/builtins/automation/contracts.py | 5 ++ app/plugins/builtins/calendar/contracts.py | 5 ++ app/plugins/builtins/dms/contracts.py | 5 ++ app/plugins/builtins/mail/contracts.py | 5 ++ .../builtins/unified_search/contracts.py | 33 ++++++++++ app/workflows/step_handlers.py | 63 +++++++++++-------- 6 files changed, 89 insertions(+), 27 deletions(-) diff --git a/app/plugins/builtins/automation/contracts.py b/app/plugins/builtins/automation/contracts.py index 0d2ae6e..d5517b1 100644 --- a/app/plugins/builtins/automation/contracts.py +++ b/app/plugins/builtins/automation/contracts.py @@ -63,6 +63,11 @@ class AutomationContract: # ─── agent_comm ─── send_agent_message = staticmethod(send_agent_message) + @classmethod + def get_function(cls, name: str): + """Return a callable exposed by this contract, or None if absent.""" + return getattr(cls, name, None) + # ─── self-registration ─── diff --git a/app/plugins/builtins/calendar/contracts.py b/app/plugins/builtins/calendar/contracts.py index 052d7eb..3c9830c 100644 --- a/app/plugins/builtins/calendar/contracts.py +++ b/app/plugins/builtins/calendar/contracts.py @@ -15,6 +15,11 @@ class CalendarContract: CalendarEntry = CalendarEntry CalendarEntryLink = CalendarEntryLink + @classmethod + def get_function(cls, name: str): + """Return a callable exposed by this contract, or None if absent.""" + return getattr(cls, name, None) + # ─── self-registration ─── diff --git a/app/plugins/builtins/dms/contracts.py b/app/plugins/builtins/dms/contracts.py index c25abe8..68a661a 100644 --- a/app/plugins/builtins/dms/contracts.py +++ b/app/plugins/builtins/dms/contracts.py @@ -15,6 +15,11 @@ class DmsContract: DmsFile = DmsFile Folder = Folder + @classmethod + def get_function(cls, name: str): + """Return a callable exposed by this contract, or None if absent.""" + return getattr(cls, name, None) + # ─── self-registration ─── diff --git a/app/plugins/builtins/mail/contracts.py b/app/plugins/builtins/mail/contracts.py index e998238..55f2862 100644 --- a/app/plugins/builtins/mail/contracts.py +++ b/app/plugins/builtins/mail/contracts.py @@ -32,6 +32,11 @@ class MailContract: # ─── models ─── Mail = Mail + @classmethod + def get_function(cls, name: str): + """Return a callable exposed by this contract, or None if absent.""" + return getattr(cls, name, None) + # ─── self-registration ─── diff --git a/app/plugins/builtins/unified_search/contracts.py b/app/plugins/builtins/unified_search/contracts.py index e128798..b9fc553 100644 --- a/app/plugins/builtins/unified_search/contracts.py +++ b/app/plugins/builtins/unified_search/contracts.py @@ -10,6 +10,33 @@ from app.plugins.builtins.unified_search.query_understanding import llm_analyze_ from app.plugins.builtins.unified_search.search_engine import find_similar_all_types, hybrid_search +async def simple_search( + db: Any, + query: str, + tenant_id: Any, + entity_types: list[str] | None = None, + limit: int = 20, + user_id: Any | None = None, + is_system_admin: bool = False, +) -> list[dict[str, Any]]: + """Convenience search entry point: analyze a raw query string and run + the hybrid search over all registered providers. + + Falls back to a plain normalized-query analysis when the LLM is + unavailable. + """ + analysis = await llm_analyze_query(query, db=db, tenant_id=tenant_id) + return await hybrid_search( + db=db, + query_analysis=analysis, + tenant_id=tenant_id, + entity_types=entity_types, + limit=limit, + user_id=user_id, + is_system_admin=is_system_admin, + ) + + class UnifiedSearchContract: """Public contract for the unified_search plugin.""" @@ -20,8 +47,14 @@ class UnifiedSearchContract: find_similar_all_types = staticmethod(find_similar_all_types) get_search_registry = staticmethod(get_search_registry) llm_analyze_query = staticmethod(llm_analyze_query) + simple_search = staticmethod(simple_search) BaseSearchProvider = BaseSearchProvider + @classmethod + def get_function(cls, name: str): + """Return a callable exposed by this contract, or None if absent.""" + return getattr(cls, name, None) + # ─── self-registration ─── diff --git a/app/workflows/step_handlers.py b/app/workflows/step_handlers.py index a72232a..827b5f1 100644 --- a/app/workflows/step_handlers.py +++ b/app/workflows/step_handlers.py @@ -218,11 +218,13 @@ async def _handle_mail( return StepResult(error="mail step requires to and subject", abort=True) try: - from app.plugins.builtins.mail.contracts import MailContract - contract = MailContract + from app.plugins.builtins.contracts import get_contract + contract = get_contract("mail") + if contract is None: + return StepResult(error="mail plugin not available", abort=True) send_fn = contract.get_function("send_email") if send_fn is None: - return StepResult(error="mail plugin not available", abort=True) + return StepResult(error="mail send_email not exposed via contract yet", abort=True) result = await send_fn( db=db, @@ -258,12 +260,14 @@ async def _handle_calendar( action = config.get("action", "create") try: - from app.plugins.builtins.calendar.contracts import CalendarContract - contract = CalendarContract + from app.plugins.builtins.contracts import get_contract + contract = get_contract("calendar") + if contract is None: + return StepResult(error="calendar plugin not available", abort=True) if action == "create": fn = contract.get_function("create_event") if fn is None: - return StepResult(error="calendar plugin not available", abort=True) + return StepResult(error="calendar create_event not exposed via contract yet", abort=True) result = await fn( db=db, tenant_id=tenant_id, @@ -275,7 +279,7 @@ async def _handle_calendar( elif action == "delete": fn = contract.get_function("delete_event") if fn is None: - return StepResult(error="calendar plugin not available", abort=True) + return StepResult(error="calendar delete_event not exposed via contract yet", abort=True) await fn(db=db, tenant_id=tenant_id, event_id=config.get("event_id", "")) return StepResult() else: @@ -303,19 +307,21 @@ async def _handle_dms( action = config.get("action", "search") try: - from app.plugins.builtins.dms.contracts import DmsContract - contract = DmsContract + from app.plugins.builtins.contracts import get_contract + contract = get_contract("dms") + if contract is None: + return StepResult(error="dms plugin not available", abort=True) if action == "search": fn = contract.get_function("search_files") if fn is None: - return StepResult(error="dms plugin not available", abort=True) + return StepResult(error="dms search_files not exposed via contract yet", abort=True) results = await fn(db=db, tenant_id=tenant_id, query=config.get("query", "")) return StepResult(output={"files": results} if results else {}) elif action == "metadata": fn = contract.get_function("get_file_metadata") if fn is None: - return StepResult(error="dms plugin not available", abort=True) + return StepResult(error="dms get_file_metadata not exposed via contract yet", abort=True) metadata = await fn(db=db, tenant_id=tenant_id, file_id=config.get("file_id", "")) return StepResult(output={"metadata": metadata} if metadata else {}) else: @@ -348,17 +354,16 @@ async def _handle_search( return StepResult(error="search step requires query", abort=True) try: - from app.plugins.builtins.unified_search.contracts import SearchContract - contract = SearchContract - fn = contract.get_function("unified_search") - if fn is None: + from app.plugins.builtins.contracts import get_contract + contract = get_contract("unified_search") + if contract is None: return StepResult(error="search plugin not available", abort=True) - results = await fn( + results = await contract.simple_search( db=db, - tenant_id=tenant_id, query=query, - entity_type=entity_type, - limit=limit, + tenant_id=tenant_id, + entity_types=[entity_type] if entity_type else None, + limit=int(limit), ) # Store results in context for later steps instance.context["search_results"] = results @@ -391,17 +396,21 @@ async def _handle_agent( return StepResult(error="agent step requires agent_id", abort=True) try: - from app.plugins.builtins.automation.contracts import AutomationContract - contract = AutomationContract + from app.plugins.builtins.contracts import get_contract + contract = get_contract("automation") + if contract is None: + return StepResult(error="automation plugin not available", abort=True) fn = contract.get_function("run_agent") if fn is None: - return StepResult(error="automation plugin not available", abort=True) + return StepResult(error="automation run_agent not exposed via contract yet", abort=True) + # run_agent is an ARQ job function: it opens its own DB session and runs + # the agent loop to completion, so ``wait`` always ends up true here. + logger.debug("agent step wait_for_completion=%s (step always waits)", wait) result = await fn( - db=db, - tenant_id=tenant_id, - agent_id=uuid.UUID(agent_id), - input_data=user_input, - wait_for_completion=wait, + {}, + str(agent_id), + trigger_type="workflow", + trigger_data={"input": user_input}, ) instance.context["agent_result"] = result return StepResult(output={"agent_result": result} if result else {}) From 49949066d399d493ba7b18c4b939baf9231ace46 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Sun, 23 Aug 2026 13:42:30 +0200 Subject: [PATCH 6/9] feat(block-h): plugin-contributable intents for fallback action mapper --- app/ai/action_mapper.py | 44 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/app/ai/action_mapper.py b/app/ai/action_mapper.py index c257db3..fc5bfcd 100644 --- a/app/ai/action_mapper.py +++ b/app/ai/action_mapper.py @@ -6,9 +6,12 @@ Supports keyword-based intent detection for common CRM operations. from __future__ import annotations +import logging import re from typing import Any +logger = logging.getLogger(__name__) + # Precompiled patterns for intent detection _PATTERNS = { "create_contact": re.compile( @@ -27,6 +30,37 @@ _PATTERNS = { "help": re.compile(r"\b(help|what can you do|assist)\b", re.IGNORECASE), } +# Plugin-contributed intents: pattern -> callable(query, context) -> list[dict] | None. +# Registered via ``register_intent_pattern`` so plugins can extend the fallback +# mapper without touching core code (Block H / HC-A). +_CONTRIBUTED_INTENTS: list[tuple[re.Pattern[str], Any]] = [] + + +def register_intent_pattern( + pattern: str | re.Pattern[str], + handler: Any, + *, + owner: str = "", +) -> None: + """Register a plugin-contributed intent for the fallback action mapper. + + Args: + pattern: Regex (compiled or raw string) matching the user query. + handler: Callable ``(query, context) -> list[dict] | None`` producing + proposed actions when the pattern matches. + owner: Optional plugin name, used by ``unregister_intent_patterns``. + """ + compiled = re.compile(pattern) if isinstance(pattern, str) else pattern + _CONTRIBUTED_INTENTS.append((compiled, handler)) + + +def unregister_intent_patterns(owner: str) -> None: + """Remove all intents contributed by ``owner`` (plugin deactivation).""" + global _CONTRIBUTED_INTENTS + _CONTRIBUTED_INTENTS = [ + entry for entry in _CONTRIBUTED_INTENTS if getattr(entry[1], "owner_tag", None) != owner + ] + # Name extraction patterns - using single-quoted strings to avoid escaping issues _NAME_PATTERNS = [ re.compile(r"\b(?:named|called|for)\s+['\"]?([^'\".,]+)['\"]?", re.IGNORECASE), @@ -147,6 +181,16 @@ def map_query_to_actions(query: str, context: dict[str, Any] | None = None) -> l } ) + # --- Plugin-contributed intents (Block H / HC-A) --- + for pattern, handler in _CONTRIBUTED_INTENTS: + try: + if pattern.search(q): + contributed = handler(query, context) + if contributed: + actions.extend(contributed) + except Exception: + logger.warning("Contributed intent handler failed", exc_info=True) + # --- Generic fallback --- if not actions: if _PATTERNS["help"].search(q): From b7ad5294a5a38488fba664a0408ff0511e70ce49 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Sun, 23 Aug 2026 13:45:12 +0200 Subject: [PATCH 7/9] refactor(block-h): message block types resolve via plugin-contributable registry --- .../components/comm/blocks/BlockRenderer.tsx | 35 ++++++------------ .../components/comm/blocks/registrations.ts | 26 ++++++++++++++ .../src/components/comm/blocks/registry.ts | 36 +++++++++++++++++++ 3 files changed, 72 insertions(+), 25 deletions(-) create mode 100644 frontend/src/components/comm/blocks/registrations.ts create mode 100644 frontend/src/components/comm/blocks/registry.ts diff --git a/frontend/src/components/comm/blocks/BlockRenderer.tsx b/frontend/src/components/comm/blocks/BlockRenderer.tsx index dc1f43c..f3c4823 100644 --- a/frontend/src/components/comm/blocks/BlockRenderer.tsx +++ b/frontend/src/components/comm/blocks/BlockRenderer.tsx @@ -6,14 +6,8 @@ import ImageBlock from './ImageBlock'; import AudioBlock from './AudioBlock'; import VideoBlock from './VideoBlock'; import FileBlock from './FileBlock'; -import ActionCardBlock from './ActionCardBlock'; -import ContactCardBlock from './ContactCardBlock'; -import MiniAppBlock from './MiniAppBlock'; -import AgentResultBlock from './AgentResultBlock'; -import ApprovalRequestBlock from './ApprovalRequestBlock'; -import TaskCardBlock from './TaskCardBlock'; -import WorkflowCardBlock from './WorkflowCardBlock'; -import KnowledgeCardBlock from './KnowledgeCardBlock'; +import { getBlockComponent } from './registry'; +import './registrations'; // plugin-contributed block registrations interface BlockRendererProps { blocks: MessageBlock[]; @@ -51,29 +45,20 @@ const BlockRenderer: React.FC = ({ blocks }) => { return ; case 'file': return ; - case 'action_card': - return ; - case 'contact_card': - return ; - case 'miniapp': - return ; - case 'agent_result': - return ; - case 'approval_request': - return ; - case 'task_card': - return ; - case 'workflow_card': - return ; - case 'knowledge_card': - return ; - default: + default: { + // Plugin-contributed block types (Block H / HC-F) + const Contributed = getBlockComponent(block.block_type); + if (Contributed) { + const ContributedBlock = Contributed as React.FC<{ block: MessageBlock }>; + return ; + } // Fallback for unknown block types return (
Unbekannter Block-Typ: {block.block_type}
); + } } }; diff --git a/frontend/src/components/comm/blocks/registrations.ts b/frontend/src/components/comm/blocks/registrations.ts new file mode 100644 index 0000000..2046b76 --- /dev/null +++ b/frontend/src/components/comm/blocks/registrations.ts @@ -0,0 +1,26 @@ +/** + * Plugin block registrations. + * + * Currently a static import list; the dynamic plugin loader (ARCH-019 fix) + * will replace this file with manifest-driven registration. Plugins register + * their block renderers via ``registerBlockType`` with an owner tag so that + * ``unregisterBlockTypes(owner)`` can clean up on deactivation. + */ +import { registerBlockType } from './registry'; +import ActionCardBlock from './ActionCardBlock'; +import ContactCardBlock from './ContactCardBlock'; +import MiniAppBlock from './MiniAppBlock'; +import AgentResultBlock from './AgentResultBlock'; +import ApprovalRequestBlock from './ApprovalRequestBlock'; +import TaskCardBlock from './TaskCardBlock'; +import WorkflowCardBlock from './WorkflowCardBlock'; +import KnowledgeCardBlock from './KnowledgeCardBlock'; + +registerBlockType('action_card', ActionCardBlock, 'core-comm'); +registerBlockType('contact_card', ContactCardBlock, 'contacts'); +registerBlockType('miniapp', MiniAppBlock, 'kommunikation'); +registerBlockType('agent_result', AgentResultBlock, 'automation'); +registerBlockType('approval_request', ApprovalRequestBlock, 'core-approvals'); +registerBlockType('task_card', TaskCardBlock, 'tasks'); +registerBlockType('workflow_card', WorkflowCardBlock, 'workflows'); +registerBlockType('knowledge_card', KnowledgeCardBlock, 'knowledge'); diff --git a/frontend/src/components/comm/blocks/registry.ts b/frontend/src/components/comm/blocks/registry.ts new file mode 100644 index 0000000..534d408 --- /dev/null +++ b/frontend/src/components/comm/blocks/registry.ts @@ -0,0 +1,36 @@ +/** + * Message block type registry. + * + * Core owns the base block types; plugins can contribute additional block + * renderers via ``registerBlockType`` / ``unregisterBlockTypes`` during their + * frontend activation lifecycle (Block H / HC-F). + */ +import type { ComponentType } from 'react'; +import type { MessageBlock } from '@/store/commStore'; + +export type BlockComponent = ComponentType<{ block: MessageBlock }>; + +const _registry = new Map(); + +export function registerBlockType(blockType: string, component: BlockComponent, owner = ''): void { + if (_registry.has(blockType)) { + // eslint-disable-next-line no-console + console.warn(`[blockRegistry] block type '${blockType}' re-registered by '${owner || 'unknown'}' — overwriting`); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (component as any).__block_owner__ = owner; + _registry.set(blockType, component); +} + +export function unregisterBlockTypes(owner: string): void { + for (const [key, component] of _registry.entries()) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((component as any).__block_owner__ === owner) { + _registry.delete(key); + } + } +} + +export function getBlockComponent(blockType: string): BlockComponent | undefined { + return _registry.get(blockType); +} From 59fdb614e0cc1bfbf9f9785240891f7bc974c03f Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Sun, 23 Aug 2026 13:53:23 +0200 Subject: [PATCH 8/9] refactor(block-h): ai sidebar tabs resolve via plugin-contributable registry --- frontend/src/components/layout/AISidebar.tsx | 16 ++++++- frontend/src/components/layout/sidebarTabs.ts | 42 +++++++++++++++++++ frontend/src/store/uiStore.ts | 4 +- 3 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/layout/sidebarTabs.ts diff --git a/frontend/src/components/layout/AISidebar.tsx b/frontend/src/components/layout/AISidebar.tsx index ef2a35f..45ef3e6 100644 --- a/frontend/src/components/layout/AISidebar.tsx +++ b/frontend/src/components/layout/AISidebar.tsx @@ -5,6 +5,7 @@ import { SuggestionList } from '@/components/ai/SuggestionSidebar'; import { ImprovementPanel } from '@/components/ai/ImprovementPanel'; import { createSession, fetchSessions } from '@/api/ai'; import { useUIStore } from '@/store/uiStore'; +import { getContributedTabs } from './sidebarTabs'; import { useTranslation } from 'react-i18next'; import { useUsers, useGroups } from '@/api/hooks'; import { Avatar } from '@/components/ui/Avatar'; @@ -35,7 +36,7 @@ const chevronRightIcon = ( ); interface TabDef { - key: 'proactive' | 'chat' | 'notifications' | 'team' | 'chatroom'; + key: string; label: string; icon: (cls: string) => React.ReactNode; testId: string; @@ -139,6 +140,13 @@ export function AISidebar() { { key: 'notifications', label: t('topbar.notifications'), icon: bellIcon, testId: 'ai-sidebar-tab-notifications' }, { key: 'team', label: 'Team', icon: teamIcon, testId: 'ai-sidebar-tab-team' }, { key: 'chatroom', label: 'Chat', icon: chatBubbleIcon, testId: 'ai-sidebar-tab-chatroom' }, + // Plugin-contributed tabs (Block H / HC-G) + ...getContributedTabs().map((ct) => ({ + key: ct.key, + label: ct.label, + icon: ct.icon, + testId: ct.testId, + })), ]; const activeTab = tabs.find((tab) => tab.key === aiSidebarTab) || tabs[0]; @@ -209,6 +217,12 @@ export function AISidebar() { if (aiSidebarTab === 'chatroom') { return ; } + // Plugin-contributed tabs (Block H / HC-G) + const contributed = getContributedTabs().find((ct) => ct.key === aiSidebarTab); + if (contributed) { + const ContributedComponent = contributed.component; + return ; + } // chat tab if (loading) { return ( diff --git a/frontend/src/components/layout/sidebarTabs.ts b/frontend/src/components/layout/sidebarTabs.ts new file mode 100644 index 0000000..1eeaa54 --- /dev/null +++ b/frontend/src/components/layout/sidebarTabs.ts @@ -0,0 +1,42 @@ +/** + * AI sidebar tab registry. + * + * Core owns the base tabs (chat/proactive/notifications/team/chatroom); + * plugins contribute additional tabs via ``registerSidebarTab`` during their + * frontend activation lifecycle (Block H / HC-G). Contributed tabs render a + * generic container component provided by the plugin. + */ +import type { ComponentType } from 'react'; + +type IconFn = (className: string) => JSX.Element; + +export interface ContributedTab { + key: string; + label: string; + icon: IconFn; + testId: string; + component: ComponentType; +} + +const _tabs = new Map(); + +export function registerSidebarTab(tab: ContributedTab): void { + if (_tabs.has(tab.key)) { + // eslint-disable-next-line no-console + console.warn(`[sidebarTabs] tab '${tab.key}' re-registered — overwriting`); + } + _tabs.set(tab.key, tab); +} + +export function unregisterSidebarTabs(owner: string): void { + for (const [key, tab] of _tabs.entries()) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((tab.component as any).__sidebar_owner__ === owner) { + _tabs.delete(key); + } + } +} + +export function getContributedTabs(): ContributedTab[] { + return Array.from(_tabs.values()); +} diff --git a/frontend/src/store/uiStore.ts b/frontend/src/store/uiStore.ts index 848a8ff..a67094e 100644 --- a/frontend/src/store/uiStore.ts +++ b/frontend/src/store/uiStore.ts @@ -2,7 +2,9 @@ import { create } from 'zustand'; export type Theme = 'light' | 'dark' | 'system'; export type Locale = 'de' | 'en'; -export type AISidebarTab = 'proactive' | 'chat' | 'notifications' | 'team' | 'chatroom'; +// Plugin-contributed tabs add arbitrary keys (Block H / HC-G) — the union +// keeps autocomplete for the base tabs while allowing any string key. +export type AISidebarTab = 'proactive' | 'chat' | 'notifications' | 'team' | 'chatroom' | (string & {}); export interface Toast { id: string; From 801743ba11170c0508299546aebba72cb793cac6 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Sun, 23 Aug 2026 14:17:02 +0200 Subject: [PATCH 9/9] test(block-h): gate H proof - plugin contributes all extension points without core changes --- app/workflows/step_handlers.py | 5 + tests/test_gate_h.py | 169 +++++++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 tests/test_gate_h.py diff --git a/app/workflows/step_handlers.py b/app/workflows/step_handlers.py index 827b5f1..35153fd 100644 --- a/app/workflows/step_handlers.py +++ b/app/workflows/step_handlers.py @@ -61,6 +61,11 @@ def register_step_type(step_type: str): return decorator +def unregister_step_type(step_type: str) -> None: + """Remove a step handler (plugin deactivation lifecycle symmetry).""" + _HANDLERS.pop(step_type, None) + + def get_step_handler(step_type: str) -> StepHandler | None: return _HANDLERS.get(step_type) diff --git a/tests/test_gate_h.py b/tests/test_gate_h.py new file mode 100644 index 0000000..f2c3307 --- /dev/null +++ b/tests/test_gate_h.py @@ -0,0 +1,169 @@ +"""Gate H — prove that a plugin contributes ALL extension points without +changing a single core file. + +Covers: +1. Agent tool -> app.ai.tool_registry (core layer) +2. Workflow step type -> app.workflows.step_handlers +3. Fallback intent -> app.ai.action_mapper +4. Deactivate symmetry -> everything removed again +5. Re-activate -> present again, NO duplicates + +The plugin under test is defined INLINE in this file — if this test passes, +no core file had to be touched to extend the platform. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +# ─── The Gate-H test plugin (inline — zero core changes) ───────────────────── + + +def _build_gate_h_plugin(): + from app.plugins.base import BasePlugin + from app.plugins.manifest import PluginManifest + + class GateHTestPlugin(BasePlugin): + """Minimal plugin exercising every Block-H contribution point.""" + + manifest = PluginManifest( + name="gate_h_test", + version="1.0.0", + display_name="Gate H Test", + description="Proves plugin contributions without core changes.", + dependencies=[], + routes=[], + events=[], + migrations=[], + permissions=["gate_h_test:read"], + ) + + async def on_activate(self, db, service_container, event_bus) -> None: + await super().on_activate(db, service_container, event_bus) + + # 1) Agent tool via CORE registry + from app.ai.tool_registry import get_tool_registry + + async def _echo_handler(arguments: dict[str, Any], context: dict[str, Any]) -> str: + return f"gate-h says: {arguments.get('text', '')}" + + get_tool_registry().register( + name="gate_h_echo", + description="Echo text back (Gate H proof).", + parameters={ + "type": "object", + "properties": {"text": {"type": "string"}}, + "required": ["text"], + }, + handler=_echo_handler, + plugin_name=self.manifest.name, + required_permission=None, + category="test", + ) + + # 2) Workflow step type + from app.workflows.step_handlers import StepResult, register_step_type + + @register_step_type("gate_h_noop") + async def _handle_gate_h_noop(db, tenant_id, instance, step): # noqa: ANN001 + return StepResult(output={"gate_h": True}) + + # 3) Fallback intent pattern + from app.ai.action_mapper import register_intent_pattern + + def _invoice_intent(query: str, context: dict[str, Any] | None): + return [ + { + "method": "POST", + "path": "/api/v1/gate-h/invoices", + "body": {}, + "description": "Create invoice (Gate H proof)", + "confidence": 0.9, + } + ] + + _invoice_intent.owner_tag = self.manifest.name + register_intent_pattern(r"\b(create|new)\b.*\binvoice\b", _invoice_intent, owner=self.manifest.name) + + async def on_deactivate(self, db, service_container, event_bus) -> None: + # Symmetry: remove everything registered above + from app.ai.tool_registry import get_tool_registry + from app.workflows.step_handlers import unregister_step_type + from app.ai.action_mapper import unregister_intent_patterns + + get_tool_registry().unregister_plugin(self.manifest.name) + unregister_step_type("gate_h_noop") + unregister_intent_patterns(self.manifest.name) + await super().on_deactivate(db, service_container, event_bus) + + return GateHTestPlugin + + +# ─── The proof ──────────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_gate_h_full_contribution_lifecycle(): + """Activate -> contributed everywhere; deactivate -> gone; reactivate -> back, no dupes.""" + from app.ai.tool_registry import get_tool_registry + from app.workflows.step_handlers import get_step_handler + from app.ai.action_mapper import map_query_to_actions + + plugin_cls = _build_gate_h_plugin() + plugin = plugin_cls() + + # ── Before activation: nothing registered ── + assert get_tool_registry().get("gate_h_echo") is None + assert get_step_handler("gate_h_noop") is None + assert map_query_to_actions("create invoice for Acme") == [] + + # ── Activate ── + await plugin.on_activate(db=None, service_container=None, event_bus=None) + + # 1) Agent tool present in CORE registry + tool = get_tool_registry().get("gate_h_echo") + assert tool is not None + assert tool.plugin_name == "gate_h_test" + result = await tool.handler({"text": "hello"}, {}) + assert result == "gate-h says: hello" + + # 2) Workflow step type resolvable + handler = get_step_handler("gate_h_noop") + assert handler is not None + + # 3) Intent contributes actions for a FACHMODUL term unknown to core + actions = map_query_to_actions("create invoice for Acme") + assert len(actions) == 1 + assert actions[0]["path"] == "/api/v1/gate-h/invoices" + + # ── Deactivate: full symmetry ── + await plugin.on_deactivate(db=None, service_container=None, event_bus=None) + assert get_tool_registry().get("gate_h_echo") is None + assert get_step_handler("gate_h_noop") is None + assert map_query_to_actions("create invoice for Acme") == [] + + # ── Re-activate: back AND no duplicates ── + await plugin.on_activate(db=None, service_container=None, event_bus=None) + tool = get_tool_registry().get("gate_h_echo") + assert tool is not None # single entry (dict keyed by name — overwrite, not dupe) + assert get_step_handler("gate_h_noop") is not None + actions = map_query_to_actions("create invoice for Acme") + assert len(actions) == 1 # exactly one contributed intent, not two + + # Cleanup so other tests are unaffected + await plugin.on_deactivate(db=None, service_container=None, event_bus=None) + + +@pytest.mark.asyncio +async def test_gate_h_agent_runtime_survives_ai_assistant_absence(): + """The core tool registry works even though ai_assistant plugin module is never imported.""" + from app.ai.tool_registry import ToolRegistry, get_tool_registry + + reg = get_tool_registry() + assert isinstance(reg, ToolRegistry) + # And it must be THE same singleton the shim exposes + from app.plugins.builtins.ai_assistant.tool_registry import get_tool_registry as shim_get + + assert shim_get() is reg