diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_connectionTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_connectionTools.py index 7073429f..b0381da2 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/_connectionTools.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/_connectionTools.py @@ -9,6 +9,7 @@ from modules.serviceCenter.services.serviceAgent.datamodelAgent import ToolResul from modules.serviceCenter.services.serviceAgent.toolRegistry import ToolRegistry from modules.serviceCenter.services.serviceAgent.coreTools._helpers import ( + _buildResolverDbFromServices, _getOrCreateTempFolder, _looksLikeBinary, _resolveFileScope, @@ -22,20 +23,6 @@ def _registerConnectionTools(registry: ToolRegistry, services): """Auto-extracted from registerCoreTools.""" # ---- Connection tools (external data sources) ---- - def _buildResolverDb(): - """Build a DB adapter that ConnectorResolver can use to load UserConnections. - interfaceDbApp has getUserConnectionById; ConnectorResolver expects getUserConnection.""" - chatService = services.chat - appIf = getattr(chatService, "interfaceDbApp", None) - if appIf and hasattr(appIf, "getUserConnectionById"): - class _Adapter: - def __init__(self, app): - self._app = app - def getUserConnection(self, connectionId: str): - return self._app.getUserConnectionById(connectionId) - return _Adapter(appIf) - return getattr(chatService, "interfaceDbComponent", None) - async def _listConnections(args: Dict[str, Any], context: Dict[str, Any]): try: chatService = services.chat @@ -49,7 +36,12 @@ def _registerConnectionTools(registry: ToolRegistry, services): authorityVal = authority.value if hasattr(authority, "value") else str(authority) username = conn.get("externalUsername", "") if isinstance(conn, dict) else getattr(conn, "externalUsername", "") email = conn.get("externalEmail", "") if isinstance(conn, dict) else getattr(conn, "externalEmail", "") - lines.append(f"- connectionId: {connId} | {authorityVal} | {username} ({email})") + cid = conn.get("id", "") if isinstance(conn, dict) else getattr(conn, "id", "") + ref = f"connection:{authorityVal}:{username}" + lines.append( + f"- {ref} connectionId={cid} ({email}) " + f"(use this full connection: line or connectionId as connectionReference)" + ) return ToolResult(toolCallId="", toolName="listConnections", success=True, data="\n".join(lines)) except Exception as e: return ToolResult(toolCallId="", toolName="listConnections", success=False, error=str(e)) @@ -65,7 +57,7 @@ def _registerConnectionTools(registry: ToolRegistry, services): from modules.connectors.connectorResolver import ConnectorResolver resolver = ConnectorResolver( services.getService("security"), - _buildResolverDb(), + _buildResolverDbFromServices(services), ) adapter = await resolver.resolveService(connectionId, service) chatService = services.chat @@ -115,7 +107,7 @@ def _registerConnectionTools(registry: ToolRegistry, services): from modules.connectors.connectorResolver import ConnectorResolver resolver = ConnectorResolver( services.getService("security"), - _buildResolverDb(), + _buildResolverDbFromServices(services), ) adapter = await resolver.resolveService(connectionId, "outlook") diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_dataSourceTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_dataSourceTools.py index 2396560e..de64de5f 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/_dataSourceTools.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/_dataSourceTools.py @@ -9,6 +9,7 @@ from modules.serviceCenter.services.serviceAgent.datamodelAgent import ToolResul from modules.serviceCenter.services.serviceAgent.toolRegistry import ToolRegistry from modules.serviceCenter.services.serviceAgent.coreTools._helpers import ( + _buildResolverDbFromServices, _getOrCreateTempFolder, _looksLikeBinary, _resolveFileScope, @@ -88,7 +89,7 @@ def _registerDataSourceTools(registry: ToolRegistry, services): from modules.connectors.connectorResolver import ConnectorResolver resolver = ConnectorResolver( services.getService("security"), - _buildResolverDb(), + _buildResolverDbFromServices(services), ) adapter = await resolver.resolveService(connectionId, service) entries = await adapter.browse(browsePath, filter=args.get("filter")) @@ -124,7 +125,7 @@ def _registerDataSourceTools(registry: ToolRegistry, services): from modules.connectors.connectorResolver import ConnectorResolver resolver = ConnectorResolver( services.getService("security"), - _buildResolverDb(), + _buildResolverDbFromServices(services), ) adapter = await resolver.resolveService(connectionId, service) entries = await adapter.search(query, path=basePath) @@ -160,7 +161,7 @@ def _registerDataSourceTools(registry: ToolRegistry, services): fullPath = filePath if filePath.startswith("/") else f"{basePath.rstrip('/')}/{filePath}" resolver = ConnectorResolver( services.getService("security"), - _buildResolverDb(), + _buildResolverDbFromServices(services), ) adapter = await resolver.resolveService(connectionId, service) result = await adapter.download(fullPath) diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_featureSubAgentTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_featureSubAgentTools.py index 91fbb81d..5480f589 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/_featureSubAgentTools.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/_featureSubAgentTools.py @@ -201,13 +201,9 @@ def _registerFeatureSubAgentTools(registry: ToolRegistry, services): "queryFeatureInstance", _queryFeatureInstance, description=( "Query data from a feature instance (e.g. Trustee, CommCoach). " - "Delegates to a specialized sub-agent that knows the feature's data schema " - "and can browse, filter, and aggregate its tables. Use this when the user " - "has attached feature data sources or asks about feature-specific data.\n\n" - "GUIDELINES:\n" - "- Ask a precise, self-contained question (include all context the sub-agent needs).\n" - "- Combine related data needs into ONE call instead of multiple small ones.\n" - "- Avoid calling this tool repeatedly with slight variations of the same question." + "Delegates to a sub-agent that knows the feature schema. " + "Requires the feature instance id from attached feature data sources. " + "Ask one precise, self-contained question per call." ), parameters={ "type": "object", diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_helpers.py b/modules/serviceCenter/services/serviceAgent/coreTools/_helpers.py index c8793775..6919ca18 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/_helpers.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/_helpers.py @@ -3,7 +3,7 @@ """Shared helpers for core agent tools (file scope, binary detection, temp folder).""" import logging -from typing import Optional +from typing import Any, Optional logger = logging.getLogger(__name__) @@ -77,3 +77,23 @@ def _getOrCreateTempFolder(chatService) -> Optional[str]: logger.warning(f"Could not get/create Temp folder: {e}") return None + +def _buildResolverDbFromServices(services: Any): + """DB adapter for ConnectorResolver: load UserConnections by id. + + interfaceDbApp exposes getUserConnectionById; ConnectorResolver expects getUserConnection. + """ + chatService = services.chat + appIf = getattr(chatService, "interfaceDbApp", None) + if appIf and hasattr(appIf, "getUserConnectionById"): + + class _Adapter: + def __init__(self, app): + self._app = app + + def getUserConnection(self, connectionId: str): + return self._app.getUserConnectionById(connectionId) + + return _Adapter(appIf) + return getattr(chatService, "interfaceDbComponent", None) + diff --git a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py index 3638ccf6..6a4965ec 100644 --- a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py +++ b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py @@ -3,7 +3,7 @@ """Agent service: entry point for running AI agents with tool use.""" import logging -from typing import Any, Callable, Dict, List, Optional, AsyncGenerator +from typing import Any, Callable, Dict, List, Optional, Set, AsyncGenerator from modules.datamodels.datamodelAi import ( AiCallRequest, AiCallOptions, AiCallResponse, OperationTypeEnum @@ -23,6 +23,40 @@ from modules.serviceCenter.services.serviceBilling.mainServiceBilling import ( logger = logging.getLogger(__name__) + +def _toolbox_connection_authorities(services: "_ServicesAdapter") -> List[str]: + """Collect connection authority strings for toolbox gating (requiresConnection). + + The optional ``connection`` service is not always registered; fall back to + ``chat.getUserConnections()`` (same source as workspace UI). + Toolbox entries use ``microsoft`` while UserConnection may store ``msft``. + """ + seen: Set[str] = set() + try: + conn_svc = services.getService("connection") + if conn_svc and hasattr(conn_svc, "getConnections"): + for c in conn_svc.getConnections() or []: + auth = c.get("authority") if isinstance(c, dict) else getattr(c, "authority", None) + val = auth.value if hasattr(auth, "value") else str(auth or "") + if val: + seen.add(val) + except Exception: + pass + try: + chat = services.chat + if chat and hasattr(chat, "getUserConnections"): + for c in chat.getUserConnections() or []: + auth = c.get("authority") if isinstance(c, dict) else getattr(c, "authority", None) + val = auth.value if hasattr(auth, "value") else str(auth or "") + if val: + seen.add(val) + except Exception as e: + logger.debug("toolbox authorities from chat: %s", e) + if "msft" in seen: + seen.add("microsoft") + return list(seen) + + class _ServicesAdapter: """Adapter providing service access from (context, get_service).""" @@ -61,10 +95,33 @@ class _ServicesAdapter: def extraction(self): return self._getService("extraction") + @property + def rbac(self): + """Same RbacClass as workflow hub (MethodBase permission checks during discoverMethods).""" + try: + chat_svc = self.chat + app = getattr(chat_svc, "interfaceDbApp", None) + if app is not None: + return getattr(app, "rbac", None) + except Exception: + return None + return None + def getService(self, name: str): """Access any service by name.""" return self._getService(name) + def __getattr__(self, name: str): + """Resolve e.g. services.clickup for MethodClickup / ActionExecutor (discoverMethods).""" + if name.startswith("_"): + raise AttributeError(name) + try: + return self._getService(name) + except KeyError: + raise AttributeError( + f"{type(self).__name__!r} object has no attribute {name!r}" + ) from None + @property def featureCode(self) -> Optional[str]: w = self.workflow @@ -268,7 +325,12 @@ class AgentService: try: from modules.workflows.processing.shared.methodDiscovery import discoverMethods + discoverMethods(self.services) + except Exception as e: + logger.warning("discoverMethods failed before action tools: %s", e) + + try: from modules.workflows.processing.core.actionExecutor import ActionExecutor actionExecutor = ActionExecutor(self.services) adapter = ActionToolAdapter(actionExecutor) @@ -293,7 +355,7 @@ class AgentService: from modules.serviceCenter.services.serviceAgent.toolboxRegistry import getToolboxRegistry tbRegistry = getToolboxRegistry() - userConnections: List[str] = [] + userConnections: List[str] = _toolbox_connection_authorities(self.services) try: chatService = self.services.chat if hasattr(self.services, "chat") else None if chatService and hasattr(chatService, "getUserConnections"): @@ -301,7 +363,7 @@ class AgentService: for c in connections: authority = c.get("authority", "") if isinstance(c, dict) else getattr(c, "authority", "") authorityVal = authority.value if hasattr(authority, "value") else str(authority) - if authorityVal: + if authorityVal and authorityVal not in userConnections: userConnections.append(authorityVal) except Exception as e: logger.debug("Could not resolve user connections for toolbox activation: %s", e) @@ -377,6 +439,7 @@ class AgentService: activatedCount += 1 continue try: +<<<<<<< HEAD from modules.serviceCenter.services.serviceAgent.coreTools import registerCoreTools registerCoreTools(registry, self.services) if registry.isValidTool(toolName): @@ -388,6 +451,15 @@ class AgentService: try: from modules.serviceCenter.services.serviceAgent.actionToolAdapter import ActionToolAdapter from modules.workflows.processing.core.actionExecutor import ActionExecutor +======= + from modules.workflows.processing.shared.methodDiscovery import discoverMethods + from modules.workflows.processing.core.actionExecutor import ActionExecutor + from modules.serviceCenter.services.serviceAgent.actionToolAdapter import ( + ActionToolAdapter, + ) + + discoverMethods(self.services) +>>>>>>> origin/fix/click-up-connector adapter = ActionToolAdapter(ActionExecutor(self.services)) adapter.registerAll(registry) if registry.isValidTool(toolName): diff --git a/modules/serviceCenter/services/serviceAgent/toolboxRegistry.py b/modules/serviceCenter/services/serviceAgent/toolboxRegistry.py index d05cfded..344d6d10 100644 --- a/modules/serviceCenter/services/serviceAgent/toolboxRegistry.py +++ b/modules/serviceCenter/services/serviceAgent/toolboxRegistry.py @@ -173,7 +173,13 @@ def _registerDefaultToolboxes() -> None: requiresConnection="clickup", isDefault=False, tools=[ - "clickup_searchTasks", "clickup_createTask", "clickup_updateTask", + "clickup_listTasks", + "clickup_listFields", + "clickup_searchTasks", + "clickup_getTask", + "clickup_createTask", + "clickup_updateTask", + "clickup_uploadAttachment", ], ), ToolboxDefinition( diff --git a/modules/system/mainSystem.py b/modules/system/mainSystem.py index 277e7e4b..2f592988 100644 --- a/modules/system/mainSystem.py +++ b/modules/system/mainSystem.py @@ -92,6 +92,7 @@ NAVIGATION_SECTIONS = [ "icon": "FaLink", "path": "/basedata/connections", "order": 10, + "public": True, }, { "id": "files", @@ -100,6 +101,7 @@ NAVIGATION_SECTIONS = [ "icon": "FaRegFileAlt", "path": "/basedata/files", "order": 20, + "public": True, }, { "id": "prompts", @@ -108,6 +110,7 @@ NAVIGATION_SECTIONS = [ "icon": "FaLightbulb", "path": "/basedata/prompts", "order": 30, + "public": True, }, ], }, diff --git a/modules/workflows/methods/methodClickup/actions/list_fields.py b/modules/workflows/methods/methodClickup/actions/list_fields.py new file mode 100644 index 00000000..851437d7 --- /dev/null +++ b/modules/workflows/methods/methodClickup/actions/list_fields.py @@ -0,0 +1,55 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. + +import json +import logging +from typing import Any, Dict + +from modules.datamodels.datamodelChat import ActionDocument, ActionResult +from ..helpers.pathparse import parse_team_and_list + +logger = logging.getLogger(__name__) + + +async def list_fields(self, parameters: Dict[str, Any]) -> ActionResult: + """Return ClickUp custom / built-in field definitions for a list (GET /list/{id}/field).""" + connection_reference = parameters.get("connectionReference") + path_query = (parameters.get("pathQuery") or parameters.get("path") or "").strip() + list_id_param = (parameters.get("listId") or "").strip() + + if not connection_reference: + return ActionResult.isFailure(error="connectionReference is required") + + conn = self.connection.get_clickup_connection(connection_reference) + if not conn: + return ActionResult.isFailure(error="No valid ClickUp connection") + + list_id = list_id_param + team_id = "" + if not list_id: + if not path_query: + return ActionResult.isFailure( + error="Provide listId or pathQuery like /team/{teamId}/list/{listId}" + ) + team_id, list_id = parse_team_and_list(path_query) + if not list_id: + return ActionResult.isFailure( + error="path must be /team/{teamId}/list/{listId} (same as list picker / data source path)" + ) + + data = await self.services.clickup.getListFields(list_id) + if isinstance(data, dict) and data.get("error"): + return ActionResult.isFailure(error=str(data.get("error")) + (data.get("body") or "")) + + doc = ActionDocument( + documentName="clickup_list_fields.json", + documentData=json.dumps(data, ensure_ascii=False, indent=2), + mimeType="application/json", + validationMetadata={ + "actionType": "clickup.listFields", + "teamId": team_id, + "listId": list_id, + "path": path_query or f"/list/{list_id}", + }, + ) + return ActionResult.isSuccess(documents=[doc]) diff --git a/modules/workflows/methods/methodClickup/helpers/connection.py b/modules/workflows/methods/methodClickup/helpers/connection.py index d9b6d4d7..cdcd3601 100644 --- a/modules/workflows/methods/methodClickup/helpers/connection.py +++ b/modules/workflows/methods/methodClickup/helpers/connection.py @@ -3,28 +3,44 @@ """Resolve ClickUp UserConnection and configure ClickupService.""" import logging +import re from typing import Any, Dict, Optional logger = logging.getLogger(__name__) +_UUID_RE = re.compile( + r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", + re.IGNORECASE, +) + class ClickupConnectionHelper: def __init__(self, method_instance: Any): self.method = method_instance self.services = method_instance.services + def _normalize_connection_reference(self, ref: str) -> str: + """Match listConnections / getUserConnectionFromConnectionReference formats.""" + if ref.startswith("connection:"): + return ref + if _UUID_RE.match(ref): + return ref + # LLM often copies "clickup:username" without the connection: prefix + if ":" in ref: + return f"connection:{ref}" + return ref + def get_clickup_connection(self, connection_reference: str) -> Optional[Dict[str, Any]]: try: ref = (connection_reference or "").split(" [")[0].strip() if not ref: return None - user_connection = None - if ref.startswith("connection:"): - user_connection = self.services.chat.getUserConnectionFromConnectionReference(ref) - else: - app = getattr(self.services, "interfaceDbApp", None) - if app and hasattr(app, "getUserConnectionById"): - user_connection = app.getUserConnectionById(ref) + ref = self._normalize_connection_reference(ref) + chat = getattr(self.services, "chat", None) + if not chat or not hasattr(chat, "getUserConnectionFromConnectionReference"): + logger.warning("Chat service missing; cannot resolve ClickUp connection") + return None + user_connection = chat.getUserConnectionFromConnectionReference(ref) if not user_connection: logger.warning("No user connection for reference/id %s", connection_reference) return None diff --git a/modules/workflows/methods/methodClickup/methodClickup.py b/modules/workflows/methods/methodClickup/methodClickup.py index 00c658a5..05eba50d 100644 --- a/modules/workflows/methods/methodClickup/methodClickup.py +++ b/modules/workflows/methods/methodClickup/methodClickup.py @@ -10,6 +10,7 @@ from modules.workflows.methods.methodBase import MethodBase from .helpers.connection import ClickupConnectionHelper from .actions.list_tasks import list_tasks +from .actions.list_fields import list_fields from .actions.search_tasks import search_tasks from .actions.get_task import get_task from .actions.create_task import create_task @@ -67,6 +68,35 @@ class MethodClickup(MethodBase): }, execute=list_tasks.__get__(self, self.__class__), ), + "listFields": WorkflowActionDefinition( + actionId="clickup.listFields", + description="List custom and built-in field definitions for a ClickUp list (names, types, ids)", + dynamicMode=True, + parameters={ + "connectionReference": WorkflowActionParameter( + name="connectionReference", + type="str", + frontendType=FrontendType.USER_CONNECTION, + required=True, + description="ClickUp connection", + ), + "listId": WorkflowActionParameter( + name="listId", + type="str", + frontendType=FrontendType.TEXT, + required=False, + description="ClickUp list ID (if set, pathQuery is optional)", + ), + "pathQuery": WorkflowActionParameter( + name="pathQuery", + type="str", + frontendType=FrontendType.TEXT, + required=False, + description="Virtual path /team/{teamId}/list/{listId} (same as data source path)", + ), + }, + execute=list_fields.__get__(self, self.__class__), + ), "searchTasks": WorkflowActionDefinition( actionId="clickup.searchTasks", description="Search tasks in a ClickUp workspace (team)",