merge: Block H - Agent platform kernel (tools/steps/blocks/tabs plugin-contributable)
This commit is contained in:
@@ -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
@@ -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}'",
|
||||
|
||||
@@ -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 [])
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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
@@ -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 ───
|
||||
|
||||
|
||||
@@ -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 ───
|
||||
|
||||
|
||||
@@ -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 ───
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ───
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user