merge: Block H - Agent platform kernel (tools/steps/blocks/tabs plugin-contributable)

This commit is contained in:
Agent Zero
2026-08-23 14:47:52 +02:00
29 changed files with 917 additions and 429 deletions
+44
View File
@@ -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):
+8 -14
View File
@@ -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__)
@@ -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}'",
+1 -1
View File
@@ -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 [])
+1 -1
View File
@@ -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:
-170
View File
@@ -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")
+126
View File
@@ -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()
+9
View File
@@ -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).
+6 -47
View File
@@ -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)
@@ -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"]
@@ -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 ───
+98 -4
View File
@@ -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
@@ -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 ───
+5
View File
@@ -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 ───
+62
View File
@@ -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)
+94
View File
@@ -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,101 @@ 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")
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
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)
@@ -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",
@@ -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,
+5
View File
@@ -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 ───
@@ -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 ───
+27 -9
View File
@@ -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()
+6 -14
View File
@@ -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,
+41 -27
View File
@@ -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)
@@ -218,11 +223,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 +265,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 +284,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 +312,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 +359,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 +401,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 {})
@@ -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<BlockRendererProps> = ({ blocks }) => {
return <VideoBlock block={block} />;
case 'file':
return <FileBlock block={block} />;
case 'action_card':
return <ActionCardBlock block={block} />;
case 'contact_card':
return <ContactCardBlock block={block} />;
case 'miniapp':
return <MiniAppBlock block={block} />;
case 'agent_result':
return <AgentResultBlock block={block} />;
case 'approval_request':
return <ApprovalRequestBlock block={block} />;
case 'task_card':
return <TaskCardBlock block={block} />;
case 'workflow_card':
return <WorkflowCardBlock block={block} />;
case 'knowledge_card':
return <KnowledgeCardBlock block={block} />;
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 <ContributedBlock block={block} />;
}
// Fallback for unknown block types
return (
<div className="text-xs text-secondary-400 italic p-2 rounded bg-secondary-50">
Unbekannter Block-Typ: {block.block_type}
</div>
);
}
}
};
@@ -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');
@@ -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<string, BlockComponent>();
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);
}
+15 -1
View File
@@ -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 <ChatPanel />;
}
// Plugin-contributed tabs (Block H / HC-G)
const contributed = getContributedTabs().find((ct) => ct.key === aiSidebarTab);
if (contributed) {
const ContributedComponent = contributed.component;
return <ContributedComponent />;
}
// chat tab
if (loading) {
return (
@@ -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<string, ContributedTab>();
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());
}
+3 -1
View File
@@ -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;
+169
View File
@@ -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