# Copyright (c) 2025 Patrick Motsch # All rights reserved. """Agent service: entry point for running AI agents with tool use.""" import logging from typing import Any, Callable, Dict, List, Optional, AsyncGenerator from modules.datamodels.datamodelAi import ( AiCallRequest, AiCallOptions, AiCallResponse, OperationTypeEnum ) from modules.serviceCenter.services.serviceAgent.datamodelAgent import ( AgentConfig, AgentEvent, AgentEventTypeEnum ) from modules.serviceCenter.services.serviceAgent.toolRegistry import ToolRegistry from modules.serviceCenter.services.serviceAgent.agentLoop import runAgentLoop from modules.serviceCenter.services.serviceAgent.actionToolAdapter import ActionToolAdapter from modules.serviceCenter.services.serviceBilling.mainServiceBilling import ( getService as getBillingService, InsufficientBalanceException, BillingContextError ) logger = logging.getLogger(__name__) _MAX_TOOL_RESULT_CHARS = 50_000 _BINARY_SIGNATURES = (b"%PDF", b"\x89PNG", b"\xff\xd8\xff", b"GIF8", b"PK\x03\x04", b"Rar!", b"\x1f\x8b") def _looksLikeBinary(data: bytes, sampleSize: int = 1024) -> bool: """Detect binary content by checking for magic bytes and non-printable char ratio.""" if any(data[:8].startswith(sig) for sig in _BINARY_SIGNATURES): return True sample = data[:sampleSize] if not sample: return False nonPrintable = sum(1 for b in sample if b < 0x09 or (0x0E <= b < 0x20 and b != 0x1B)) return nonPrintable / len(sample) > 0.10 class _ServicesAdapter: """Adapter providing service access from (context, get_service).""" def __init__(self, context, getService: Callable[[str], Any]): self._context = context self._getService = getService self.user = context.user self.mandateId = context.mandate_id self.featureInstanceId = context.feature_instance_id @property def workflow(self): return self._context.workflow @property def ai(self): return self._getService("ai") @property def chat(self): return self._getService("chat") @property def streaming(self): return self._getService("streaming") @property def billing(self): return self._getService("billing") @property def utils(self): return self._getService("utils") @property def extraction(self): return self._getService("extraction") def getService(self, name: str): """Access any service by name.""" return self._getService(name) @property def featureCode(self) -> Optional[str]: w = self.workflow if w and hasattr(w, "feature") and w.feature: return getattr(w.feature, "code", None) return getattr(w, "featureCode", None) if w else None class AgentService: """Service for running AI agents with ReAct loop and tool use. Registered as IMPORTABLE_SERVICE with objectKey 'service.agent'. Uses serviceAi for model selection/billing, streaming for SSE events. """ def __init__(self, context, get_service: Callable[[str], Any]): self._context = context self._getService = get_service self.services = _ServicesAdapter(context, get_service) async def runAgent( self, prompt: str, fileIds: List[str] = None, config: AgentConfig = None, toolSet: str = "core", workflowId: str = None, additionalTools: List[Dict[str, Any]] = None, userLanguage: str = "", ) -> AsyncGenerator[AgentEvent, None]: """Run an agent with the given prompt and tools. Args: prompt: User prompt fileIds: Optional list of file IDs to include as context config: Agent configuration toolSet: Which tool set to activate workflowId: Workflow ID for tracking and billing additionalTools: Extra tool definitions to register dynamically userLanguage: ISO 639-1 language code; falls back to user.language from profile Yields: AgentEvent for each step (SSE-ready) """ if config is None: config = AgentConfig(toolSet=toolSet) if workflowId is None: workflowId = getattr(self.services.workflow, "id", "unknown") if self.services.workflow else "unknown" resolvedLanguage = userLanguage or getattr(self.services.user, "language", "") or "de" enrichedPrompt = await self._enrichPromptWithFiles(prompt, fileIds) toolRegistry = self._buildToolRegistry(config) aiCallFn = self._createAiCallFn() aiCallStreamFn = self._createAiCallStreamFn() getWorkflowCostFn = self._createGetWorkflowCostFn(workflowId) buildRagContextFn = self._createBuildRagContextFn() async for event in runAgentLoop( prompt=enrichedPrompt, toolRegistry=toolRegistry, config=config, aiCallFn=aiCallFn, getWorkflowCostFn=getWorkflowCostFn, workflowId=workflowId, userId=self.services.user.id if self.services.user else "", featureInstanceId=self.services.featureInstanceId or "", buildRagContextFn=buildRagContextFn, mandateId=self.services.mandateId or "", aiCallStreamFn=aiCallStreamFn, userLanguage=resolvedLanguage, ): if event.type == AgentEventTypeEnum.AGENT_SUMMARY: await self._persistTrace(workflowId, event.data or {}) logger.debug(f"runAgent yielding event type={event.type}") yield event logger.info(f"runAgent loop completed for workflow {workflowId}") async def _enrichPromptWithFiles(self, prompt: str, fileIds: List[str] = None) -> str: """Resolve file metadata + FileContentIndex for attached fileIds and prepend to prompt. The FileContentIndex is produced by the upload pipeline (AI-free extraction) and tells the agent exactly which content objects (text, images, tables, etc.) exist inside a file, so the agent can work with them directly via tools. """ if not fileIds: return prompt try: chatService = self.services.chat knowledgeDb = None try: from modules.interfaces.interfaceDbKnowledge import getInterface as _getKnowledgeInterface knowledgeDb = _getKnowledgeInterface() except Exception: pass fileDescriptions = [] for fid in fileIds: try: info = chatService.getFileInfo(fid) fileName = info.get("fileName", fid) if info else fid mimeType = info.get("mimeType", "unknown") if info else "unknown" fileSize = info.get("size", "?") if info else "?" desc = f"### File: {fileName}\n - id: {fid}\n - type: {mimeType}\n - size: {fileSize} bytes" if knowledgeDb: contentIndex = knowledgeDb.getFileContentIndex(fid) if contentIndex: structure = contentIndex.get("structure", {}) totalObjects = contentIndex.get("totalObjects", 0) desc += f"\n - indexed: yes ({totalObjects} content objects)" if structure: structParts = [] for key, val in structure.items(): if isinstance(val, (int, str)): structParts.append(f"{key}: {val}") if structParts: desc += f"\n - structure: {', '.join(structParts)}" objectSummary = contentIndex.get("objectSummary", []) if objectSummary: desc += "\n - content objects:" for obj in objectSummary[:20]: objType = obj.get("type", obj.get("contentType", "?")) objRef = obj.get("ref", {}) objLabel = objRef.get("location", "") if isinstance(objRef, dict) else "" objId = obj.get("id", obj.get("contentObjectId", "")) desc += f"\n * [{objType}] {objLabel}" + (f" (id: {objId})" if objId else "") if len(objectSummary) > 20: desc += f"\n ... and {len(objectSummary) - 20} more objects" else: desc += "\n - indexed: no (use readFile to trigger extraction)" fileDescriptions.append(desc) except Exception: fileDescriptions.append(f"### File id: {fid}") if fileDescriptions: header = ( "## Attached Files\n" "These files have been uploaded and processed through the extraction pipeline.\n" "Use `readFile(fileId)` to read text content, `readContentObjects(fileId)` for structured access, " "or `describeImage(fileId)` for image analysis.\n" "When generating documents with `renderDocument`, embed images using `![alt text](file:fileId)` in the markdown content.\n\n" ) header += "\n\n".join(fileDescriptions) return f"{header}\n\n---\n\nUser request: {prompt}" except Exception as e: logger.warning(f"Could not enrich prompt with file metadata: {e}") return prompt def _buildToolRegistry(self, config: AgentConfig) -> ToolRegistry: """Build a tool registry with core tools and ActionToolAdapter tools.""" registry = ToolRegistry() _registerCoreTools(registry, self.services) try: from modules.workflows.processing.core.actionExecutor import ActionExecutor actionExecutor = ActionExecutor(self.services) adapter = ActionToolAdapter(actionExecutor) adapter.registerAll(registry) except Exception as e: logger.warning(f"Could not register action tools: {e}") return registry async def _persistTrace(self, workflowId: str, summaryData: Dict[str, Any]): """Persist the agent trace as a workflow memory entry in the knowledge store.""" try: knowledgeService = self._getService("knowledge") userId = self.services.user.id if self.services.user else "" featureInstanceId = self.services.featureInstanceId or "" import json traceValue = json.dumps(summaryData, default=str) await knowledgeService.storeEntity( workflowId=workflowId, userId=userId, featureInstanceId=featureInstanceId, key="_agentTrace", value=traceValue, source="agent", ) logger.info(f"Persisted agent trace for workflow {workflowId}") except Exception as e: logger.warning(f"Could not persist agent trace: {e}") def _createAiCallFn(self) -> Callable[[AiCallRequest], AiCallResponse]: """Create the AI call function that wraps serviceAi with billing.""" async def _aiCallFn(request: AiCallRequest) -> AiCallResponse: aiService = self.services.ai return await aiService.callAi(request) return _aiCallFn def _createAiCallStreamFn(self): """Create the streaming AI call function. Yields str deltas, then AiCallResponse.""" async def _aiCallStreamFn(request: AiCallRequest): aiService = self.services.ai async for chunk in aiService.callAiStream(request): yield chunk return _aiCallStreamFn def _createGetWorkflowCostFn(self, workflowId: str) -> Callable[[], float]: """Create a function that returns the current workflow cost.""" async def _getWorkflowCost() -> float: try: billingService = self.services.billing return await billingService.getWorkflowCost(workflowId) except Exception: return 0.0 return _getWorkflowCost def _createBuildRagContextFn(self): """Create the RAG context builder function that delegates to KnowledgeService.""" async def _buildRagContext( currentPrompt: str, workflowId: str, userId: str, featureInstanceId: str, mandateId: str, **kwargs ) -> str: try: knowledgeService = self.services.getService("knowledge") return await knowledgeService.buildAgentContext( currentPrompt=currentPrompt, workflowId=workflowId, userId=userId, featureInstanceId=featureInstanceId, mandateId=mandateId, ) except Exception as e: logger.debug(f"RAG context not available: {e}") return "" return _buildRagContext def _registerCoreTools(registry: ToolRegistry, services): """Register built-in core tools: file operations, search, and folder management.""" from modules.serviceCenter.services.serviceAgent.datamodelAgent import ToolResult # ---- Read-only tools ---- async def _readFile(args: Dict[str, Any], context: Dict[str, Any]): fileId = args.get("fileId", "") if not fileId: return ToolResult(toolCallId="", toolName="readFile", success=False, error="fileId is required") try: knowledgeService = services.getService("knowledge") if hasattr(services, "getService") else None # 1) Knowledge Store: return already-extracted text chunks if knowledgeService: fileStatus = knowledgeService.getFileStatus(fileId) if fileStatus == "indexed": chunks = knowledgeService._knowledgeDb.getContentChunks(fileId) textChunks = [ c for c in (chunks or []) if c.get("contentType") != "image" and c.get("data") ] if textChunks: assembled = "\n\n".join(c["data"] for c in textChunks) if len(assembled) > _MAX_TOOL_RESULT_CHARS: assembled = assembled[:_MAX_TOOL_RESULT_CHARS] + f"\n\n[Truncated – showing first {_MAX_TOOL_RESULT_CHARS} chars of {len(assembled)}]" return ToolResult( toolCallId="", toolName="readFile", success=True, data=assembled, ) elif fileStatus in ("processing", "embedding", "extracted"): return ToolResult( toolCallId="", toolName="readFile", success=True, data=f"[File {fileId} is currently being processed (status: {fileStatus}). Try again shortly.]", ) # 2) Not indexed yet: try on-demand extraction chatService = services.chat fileInfo = chatService.getFileInfo(fileId) if not fileInfo: return ToolResult(toolCallId="", toolName="readFile", success=True, data="File not found.") fileName = fileInfo.get("fileName", fileId) mimeType = fileInfo.get("mimeType", "") _BINARY_TYPES = ("application/pdf", "image/", "application/vnd.", "application/zip", "application/x-zip", "application/x-tar", "application/x-7z", "application/msword", "application/octet-stream") isBinary = any(mimeType.startswith(t) for t in _BINARY_TYPES) rawBytes = chatService.getFileData(fileId) if not rawBytes: return ToolResult(toolCallId="", toolName="readFile", success=True, data="File data not accessible.") if not isBinary: isBinary = _looksLikeBinary(rawBytes) if isBinary: try: from modules.serviceCenter.services.serviceExtraction.subRegistry import ExtractorRegistry, ChunkerRegistry from modules.serviceCenter.services.serviceExtraction.subPipeline import runExtraction from modules.datamodels.datamodelExtraction import ExtractionOptions extracted = runExtraction( ExtractorRegistry(), ChunkerRegistry(), rawBytes, fileName, mimeType, ExtractionOptions(), ) contentObjects = [] for part in extracted.parts: tg = (part.typeGroup or "").lower() ct = "image" if tg == "image" else "text" if not part.data or not part.data.strip(): continue contentObjects.append({ "contentObjectId": part.id, "contentType": ct, "data": part.data, "contextRef": { "containerPath": fileName, "location": part.label or "file", **(part.metadata or {}), }, }) if contentObjects: if knowledgeService: try: userId = context.get("userId", "") await knowledgeService.indexFile( fileId=fileId, fileName=fileName, mimeType=mimeType, userId=userId, contentObjects=contentObjects, ) except Exception: pass textParts = [o["data"] for o in contentObjects if o["contentType"] != "image"] if textParts: joined = "\n\n".join(textParts) if len(joined) > _MAX_TOOL_RESULT_CHARS: joined = joined[:_MAX_TOOL_RESULT_CHARS] + f"\n\n[Truncated – showing first {_MAX_TOOL_RESULT_CHARS} chars of {len(joined)}]" return ToolResult( toolCallId="", toolName="readFile", success=True, data=joined, ) imgCount = sum(1 for o in contentObjects if o["contentType"] == "image") return ToolResult( toolCallId="", toolName="readFile", success=True, data=f"[Extracted {len(contentObjects)} content objects from '{fileName}' " f"({imgCount} images, no readable text). " f"Use describeImage(fileId='{fileId}') to analyze visual content.]", ) except Exception as extractErr: logger.warning(f"readFile extraction failed for {fileId} ({fileName}): {extractErr}") return ToolResult( toolCallId="", toolName="readFile", success=True, data=f"[Binary file: '{fileName}', type={mimeType}, size={len(rawBytes)} bytes. " f"Text extraction not available. Use describeImage for images.]", ) # 3) Text file: decode raw bytes for encoding in ("utf-8", "utf-8-sig", "latin-1"): try: text = rawBytes.decode(encoding) if text.strip(): if len(text) > _MAX_TOOL_RESULT_CHARS: text = text[:_MAX_TOOL_RESULT_CHARS] + f"\n\n[Truncated – showing first {_MAX_TOOL_RESULT_CHARS} chars of {len(text)}]" return ToolResult( toolCallId="", toolName="readFile", success=True, data=text, ) except (UnicodeDecodeError, ValueError): continue return ToolResult( toolCallId="", toolName="readFile", success=True, data="File is empty or could not be decoded.", ) except Exception as e: return ToolResult(toolCallId="", toolName="readFile", success=False, error=str(e)) async def _listFiles(args: Dict[str, Any], context: Dict[str, Any]): try: chatService = services.chat files = chatService.listFiles( folderId=args.get("folderId"), tags=args.get("tags"), search=args.get("search"), ) fileList = "\n".join( f"- {f.get('fileName', 'unknown')} (id: {f.get('id', '?')}, " f"type: {f.get('mimeType', '?')}, size: {f.get('fileSize', '?')}, " f"tags: {f.get('tags', [])}, status: {f.get('status', 'n/a')})" for f in files ) if files else "No files found." return ToolResult(toolCallId="", toolName="listFiles", success=True, data=fileList) except Exception as e: return ToolResult(toolCallId="", toolName="listFiles", success=False, error=str(e)) async def _searchFiles(args: Dict[str, Any], context: Dict[str, Any]): query = args.get("query", "") if not query: return ToolResult(toolCallId="", toolName="searchFiles", success=False, error="query is required") try: chatService = services.chat files = chatService.listFiles(search=query, tags=args.get("tags")) fileList = "\n".join( f"- {f.get('fileName', 'unknown')} (id: {f.get('id', '?')})" for f in files ) if files else "No files matching query." return ToolResult(toolCallId="", toolName="searchFiles", success=True, data=fileList) except Exception as e: return ToolResult(toolCallId="", toolName="searchFiles", success=False, error=str(e)) async def _listFolders(args: Dict[str, Any], context: Dict[str, Any]): try: chatService = services.chat folders = chatService.listFolders(parentId=args.get("parentId")) folderList = "\n".join( f"- {f.get('name', 'unnamed')} (id: {f.get('id', '?')})" for f in folders ) if folders else "No folders found." return ToolResult(toolCallId="", toolName="listFolders", success=True, data=folderList) except Exception as e: return ToolResult(toolCallId="", toolName="listFolders", success=False, error=str(e)) async def _webSearch(args: Dict[str, Any], context: Dict[str, Any]): query = args.get("query", "") if not query: return ToolResult(toolCallId="", toolName="webSearch", success=False, error="query is required") try: webService = services.getService("web") result = await webService.performWebResearch( prompt=query, urls=[], country=None, language=args.get("language"), ) summary = result.get("summary", "") if isinstance(result, dict) else str(result) return ToolResult( toolCallId="", toolName="webSearch", success=True, data=summary or str(result) ) except Exception as e: return ToolResult(toolCallId="", toolName="webSearch", success=False, error=str(e)) # ---- Write tools ---- async def _tagFile(args: Dict[str, Any], context: Dict[str, Any]): fileId = args.get("fileId", "") tags = args.get("tags", []) if not fileId: return ToolResult(toolCallId="", toolName="tagFile", success=False, error="fileId is required") try: chatService = services.chat chatService.interfaceDbComponent.updateFile(fileId, {"tags": tags}) return ToolResult( toolCallId="", toolName="tagFile", success=True, data=f"Tags updated to {tags} for file {fileId}" ) except Exception as e: return ToolResult(toolCallId="", toolName="tagFile", success=False, error=str(e)) async def _moveFile(args: Dict[str, Any], context: Dict[str, Any]): fileId = args.get("fileId", "") targetFolderId = args.get("targetFolderId") if not fileId: return ToolResult(toolCallId="", toolName="moveFile", success=False, error="fileId is required") try: chatService = services.chat chatService.interfaceDbComponent.updateFile(fileId, {"folderId": targetFolderId}) return ToolResult( toolCallId="", toolName="moveFile", success=True, data=f"File {fileId} moved to folder {targetFolderId or 'root'}" ) except Exception as e: return ToolResult(toolCallId="", toolName="moveFile", success=False, error=str(e)) async def _createFolder(args: Dict[str, Any], context: Dict[str, Any]): name = args.get("name", "") if not name: return ToolResult(toolCallId="", toolName="createFolder", success=False, error="name is required") try: chatService = services.chat folder = chatService.createFolder(name=name, parentId=args.get("parentId")) return ToolResult( toolCallId="", toolName="createFolder", success=True, data=f"Folder '{name}' created (id: {folder.get('id', '?')})" ) except Exception as e: return ToolResult(toolCallId="", toolName="createFolder", success=False, error=str(e)) async def _writeFile(args: Dict[str, Any], context: Dict[str, Any]): name = args.get("name", "") content = args.get("content", "") if not name: return ToolResult(toolCallId="", toolName="writeFile", success=False, error="name is required") try: chatService = services.chat fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile( content.encode("utf-8"), name ) fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "") if fiId: chatService.interfaceDbComponent.updateFile(fileItem.id, {"featureInstanceId": fiId}) if args.get("folderId"): chatService.interfaceDbComponent.updateFile(fileItem.id, {"folderId": args["folderId"]}) if args.get("tags"): chatService.interfaceDbComponent.updateFile(fileItem.id, {"tags": args["tags"]}) return ToolResult( toolCallId="", toolName="writeFile", success=True, data=f"File '{name}' created (id: {fileItem.id})", sideEvents=[{ "type": "fileCreated", "data": { "fileId": fileItem.id, "fileName": name, "mimeType": fileItem.mimeType, "fileSize": fileItem.fileSize, }, }], ) except Exception as e: return ToolResult(toolCallId="", toolName="writeFile", success=False, error=str(e)) # ---- Register all tools ---- registry.register( "readFile", _readFile, description="Read the content of a file by its fileId.", parameters={ "type": "object", "properties": {"fileId": {"type": "string", "description": "The file ID to read"}}, "required": ["fileId"] }, readOnly=True ) registry.register( "listFiles", _listFiles, description="List LOCAL workspace files (uploaded/generated). NOT for external data sources -- use browseDataSource instead.", parameters={ "type": "object", "properties": { "folderId": {"type": "string", "description": "Filter by folder ID"}, "tags": {"type": "array", "items": {"type": "string"}, "description": "Filter by tags (any match)"}, "search": {"type": "string", "description": "Search in file names and descriptions"}, } }, readOnly=True ) registry.register( "searchFiles", _searchFiles, description="Search LOCAL workspace files by name, description, or tags. NOT for external data sources -- use searchDataSource instead.", parameters={ "type": "object", "properties": { "query": {"type": "string", "description": "Search query"}, "tags": {"type": "array", "items": {"type": "string"}, "description": "Additional tag filter"}, }, "required": ["query"] }, readOnly=True ) registry.register( "listFolders", _listFolders, description="List LOCAL workspace folders. NOT for external data sources -- use browseDataSource instead.", parameters={ "type": "object", "properties": { "parentId": {"type": "string", "description": "Parent folder ID (omit for root)"}, } }, readOnly=True ) registry.register( "webSearch", _webSearch, description="Search the web for information.", parameters={ "type": "object", "properties": {"query": {"type": "string", "description": "Search query"}}, "required": ["query"] }, readOnly=True ) registry.register( "tagFile", _tagFile, description="Set tags on a file for categorization.", parameters={ "type": "object", "properties": { "fileId": {"type": "string", "description": "The file ID"}, "tags": {"type": "array", "items": {"type": "string"}, "description": "Tags to set"}, }, "required": ["fileId", "tags"] }, readOnly=False ) registry.register( "moveFile", _moveFile, description="Move a file to a different folder.", parameters={ "type": "object", "properties": { "fileId": {"type": "string", "description": "The file ID to move"}, "targetFolderId": {"type": "string", "description": "Target folder ID (null for root)"}, }, "required": ["fileId"] }, readOnly=False ) registry.register( "createFolder", _createFolder, description="Create a new file folder.", parameters={ "type": "object", "properties": { "name": {"type": "string", "description": "Folder name"}, "parentId": {"type": "string", "description": "Parent folder ID (omit for root)"}, }, "required": ["name"] }, readOnly=False ) registry.register( "writeFile", _writeFile, description="Create a new file with text content.", parameters={ "type": "object", "properties": { "name": {"type": "string", "description": "File name including extension"}, "content": {"type": "string", "description": "File content as text"}, "folderId": {"type": "string", "description": "Target folder ID"}, "tags": {"type": "array", "items": {"type": "string"}, "description": "Tags"}, }, "required": ["name", "content"] }, readOnly=False ) # ---- 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 connections = chatService.getUserConnections() if hasattr(chatService, "getUserConnections") else [] if not connections: return ToolResult(toolCallId="", toolName="listConnections", success=True, data="No connections available.") lines = [] for conn in connections: connId = conn.get("id", "?") if isinstance(conn, dict) else getattr(conn, "id", "?") authority = conn.get("authority", "?") if isinstance(conn, dict) else getattr(conn, "authority", "?") email = conn.get("externalEmail", "") if isinstance(conn, dict) else getattr(conn, "externalEmail", "") lines.append(f"- {authority} ({email}) id: {connId}") 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)) async def _externalBrowse(args: Dict[str, Any], context: Dict[str, Any]): connectionId = args.get("connectionId", "") service = args.get("service", "") path = args.get("path", "/") if not connectionId or not service: return ToolResult(toolCallId="", toolName="externalBrowse", success=False, error="connectionId and service are required") try: from modules.connectors.connectorResolver import ConnectorResolver resolver = ConnectorResolver( services.getService("security"), _buildResolverDb(), ) adapter = await resolver.resolveService(connectionId, service) entries = await adapter.browse(path, filter=args.get("filter")) entryLines = "\n".join( f"- {'[DIR]' if e.isFolder else '[FILE]'} {e.name} ({e.size or '?'} bytes)" for e in entries ) if entries else "Empty directory." return ToolResult(toolCallId="", toolName="externalBrowse", success=True, data=entryLines) except Exception as e: return ToolResult(toolCallId="", toolName="externalBrowse", success=False, error=str(e)) async def _externalDownload(args: Dict[str, Any], context: Dict[str, Any]): connectionId = args.get("connectionId", "") service = args.get("service", "") path = args.get("path", "") if not connectionId or not service or not path: return ToolResult(toolCallId="", toolName="externalDownload", success=False, error="connectionId, service, and path are required") try: from modules.connectors.connectorResolver import ConnectorResolver resolver = ConnectorResolver( services.getService("security"), _buildResolverDb(), ) adapter = await resolver.resolveService(connectionId, service) fileBytes = await adapter.download(path) if not fileBytes: return ToolResult(toolCallId="", toolName="externalDownload", success=False, error="Download returned empty") fileName = path.split("/")[-1] or "downloaded_file" chatService = services.chat fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile(fileBytes, fileName) ext = fileName.rsplit(".", 1)[-1].lower() if "." in fileName else "" hint = "Use readFile to read text content." if ext in ("doc", "docx", "txt", "csv", "json", "xml", "html", "md", "rtf", "odt", "xls", "xlsx", "pptx") else "Use readFile to access the content." return ToolResult( toolCallId="", toolName="externalDownload", success=True, data=f"Downloaded '{fileName}' ({len(fileBytes)} bytes) → local file id: {fileItem.id}. {hint}" ) except Exception as e: return ToolResult(toolCallId="", toolName="externalDownload", success=False, error=str(e)) async def _externalUpload(args: Dict[str, Any], context: Dict[str, Any]): connectionId = args.get("connectionId", "") service = args.get("service", "") path = args.get("path", "") fileId = args.get("fileId", "") if not connectionId or not service or not path or not fileId: return ToolResult(toolCallId="", toolName="externalUpload", success=False, error="connectionId, service, path, and fileId are required") try: from modules.connectors.connectorResolver import ConnectorResolver resolver = ConnectorResolver( services.getService("security"), _buildResolverDb(), ) adapter = await resolver.resolveService(connectionId, service) chatService = services.chat fileContent = chatService.getFileContent(fileId) if not fileContent: return ToolResult(toolCallId="", toolName="externalUpload", success=False, error="File not found") fileData = fileContent.get("data", b"") if isinstance(fileContent, dict) else b"" if isinstance(fileData, str): fileData = fileData.encode("utf-8") fileName = fileContent.get("fileName", "file") if isinstance(fileContent, dict) else "file" result = await adapter.upload(path, fileData, fileName) return ToolResult(toolCallId="", toolName="externalUpload", success=True, data=str(result)) except Exception as e: return ToolResult(toolCallId="", toolName="externalUpload", success=False, error=str(e)) async def _externalSearch(args: Dict[str, Any], context: Dict[str, Any]): connectionId = args.get("connectionId", "") service = args.get("service", "") query = args.get("query", "") if not connectionId or not service or not query: return ToolResult(toolCallId="", toolName="externalSearch", success=False, error="connectionId, service, and query are required") try: from modules.connectors.connectorResolver import ConnectorResolver resolver = ConnectorResolver( services.getService("security"), _buildResolverDb(), ) adapter = await resolver.resolveService(connectionId, service) entries = await adapter.search(query, path=args.get("path")) resultLines = "\n".join( f"- {e.name} ({e.path})" for e in entries ) if entries else "No results found." return ToolResult(toolCallId="", toolName="externalSearch", success=True, data=resultLines) except Exception as e: return ToolResult(toolCallId="", toolName="externalSearch", success=False, error=str(e)) async def _sendMail(args: Dict[str, Any], context: Dict[str, Any]): connectionId = args.get("connectionId", "") to = args.get("to", []) subject = args.get("subject", "") body = args.get("body", "") if not connectionId or not to or not subject: return ToolResult(toolCallId="", toolName="sendMail", success=False, error="connectionId, to, and subject are required") try: from modules.connectors.connectorResolver import ConnectorResolver resolver = ConnectorResolver( services.getService("security"), _buildResolverDb(), ) adapter = await resolver.resolveService(connectionId, "outlook") if hasattr(adapter, "sendMail"): result = await adapter.sendMail(to=to, subject=subject, body=body, cc=args.get("cc")) return ToolResult(toolCallId="", toolName="sendMail", success=True, data=str(result)) return ToolResult(toolCallId="", toolName="sendMail", success=False, error="Mail not supported by this adapter") except Exception as e: return ToolResult(toolCallId="", toolName="sendMail", success=False, error=str(e)) _connToolParams = { "connectionId": {"type": "string", "description": "UserConnection ID"}, "service": {"type": "string", "description": "Service name (sharepoint, outlook, drive, etc.)"}, } registry.register( "listConnections", _listConnections, description="List available external connections and their services.", parameters={"type": "object", "properties": {}}, readOnly=True, ) registry.register( "externalBrowse", _externalBrowse, description="Browse files in an external source by connectionId+service. For ATTACHED data sources, prefer browseDataSource instead.", parameters={ "type": "object", "properties": { **_connToolParams, "path": {"type": "string", "description": "Path to browse"}, "filter": {"type": "string", "description": "Filter pattern (e.g. '*.pdf')"}, }, "required": ["connectionId", "service"], }, readOnly=True, ) registry.register( "externalDownload", _externalDownload, description="Download a file from an external source into local storage + auto-index.", parameters={ "type": "object", "properties": { **_connToolParams, "path": {"type": "string", "description": "File path to download"}, }, "required": ["connectionId", "service", "path"], }, readOnly=False, ) registry.register( "externalUpload", _externalUpload, description="Upload a local file to an external data source.", parameters={ "type": "object", "properties": { **_connToolParams, "path": {"type": "string", "description": "Destination path"}, "fileId": {"type": "string", "description": "Local file ID to upload"}, }, "required": ["connectionId", "service", "path", "fileId"], }, readOnly=False, ) registry.register( "externalSearch", _externalSearch, description="Search files in an external source by connectionId+service. For ATTACHED data sources, prefer searchDataSource instead.", parameters={ "type": "object", "properties": { **_connToolParams, "query": {"type": "string", "description": "Search query"}, "path": {"type": "string", "description": "Scope to a specific path"}, }, "required": ["connectionId", "service", "query"], }, readOnly=True, ) registry.register( "sendMail", _sendMail, description="Send an email via a connected mail service (Outlook, Gmail).", parameters={ "type": "object", "properties": { "connectionId": {"type": "string", "description": "UserConnection ID"}, "to": {"type": "array", "items": {"type": "string"}, "description": "Recipient email addresses"}, "subject": {"type": "string", "description": "Email subject"}, "body": {"type": "string", "description": "Email body text"}, "cc": {"type": "array", "items": {"type": "string"}, "description": "CC addresses"}, }, "required": ["connectionId", "to", "subject", "body"], }, readOnly=False, ) # ---- DataSource convenience tools ---- _SOURCE_TYPE_TO_SERVICE = { "sharepointFolder": "sharepoint", "onedriveFolder": "onedrive", "outlookFolder": "outlook", "googleDriveFolder": "drive", "gmailFolder": "gmail", "ftpFolder": "files", } async def _resolveDataSource(dsId: str): """Resolve a DataSource record and return (connectionId, service, path) or raise.""" chatService = services.chat ds = chatService.getDataSource(dsId) if hasattr(chatService, "getDataSource") else None if not ds: raise ValueError(f"DataSource '{dsId}' not found") connectionId = ds.get("connectionId", "") sourceType = ds.get("sourceType", "") path = ds.get("path", "/") label = ds.get("label", "") service = _SOURCE_TYPE_TO_SERVICE.get(sourceType, sourceType) if not connectionId: raise ValueError(f"DataSource '{dsId}' has no connectionId") logger.info(f"Resolved DataSource '{dsId}' ({label}): sourceType={sourceType}, service={service}, connectionId={connectionId}, path={path[:80]}") return connectionId, service, path async def _browseDataSource(args: Dict[str, Any], context: Dict[str, Any]): dsId = args.get("dataSourceId", "") subPath = args.get("subPath", "") if not dsId: return ToolResult(toolCallId="", toolName="browseDataSource", success=False, error="dataSourceId is required") try: connectionId, service, basePath = await _resolveDataSource(dsId) if subPath: if subPath.startswith("/"): browsePath = subPath else: browsePath = f"{basePath.rstrip('/')}/{subPath}" else: browsePath = basePath from modules.connectors.connectorResolver import ConnectorResolver resolver = ConnectorResolver( services.getService("security"), _buildResolverDb(), ) adapter = await resolver.resolveService(connectionId, service) entries = await adapter.browse(browsePath, filter=args.get("filter")) if not entries: return ToolResult(toolCallId="", toolName="browseDataSource", success=True, data="Empty directory.") lines = [] for e in entries: prefix = "[DIR]" if e.isFolder else "[FILE]" sizeInfo = f" ({e.size} bytes)" if e.size else "" lines.append(f"- {prefix} {e.name}{sizeInfo} path: {e.path}") return ToolResult(toolCallId="", toolName="browseDataSource", success=True, data="\n".join(lines)) except Exception as e: return ToolResult(toolCallId="", toolName="browseDataSource", success=False, error=str(e)) async def _searchDataSource(args: Dict[str, Any], context: Dict[str, Any]): dsId = args.get("dataSourceId", "") query = args.get("query", "") if not dsId or not query: return ToolResult(toolCallId="", toolName="searchDataSource", success=False, error="dataSourceId and query are required") try: connectionId, service, basePath = await _resolveDataSource(dsId) from modules.connectors.connectorResolver import ConnectorResolver resolver = ConnectorResolver( services.getService("security"), _buildResolverDb(), ) adapter = await resolver.resolveService(connectionId, service) entries = await adapter.search(query, path=basePath) if not entries: return ToolResult(toolCallId="", toolName="searchDataSource", success=True, data="No results found.") lines = [f"- {e.name} (path: {e.path})" for e in entries] return ToolResult(toolCallId="", toolName="searchDataSource", success=True, data="\n".join(lines)) except Exception as e: return ToolResult(toolCallId="", toolName="searchDataSource", success=False, error=str(e)) async def _downloadFromDataSource(args: Dict[str, Any], context: Dict[str, Any]): dsId = args.get("dataSourceId", "") filePath = args.get("filePath", "") fileName = args.get("fileName", "") if not dsId or not filePath: return ToolResult(toolCallId="", toolName="downloadFromDataSource", success=False, error="dataSourceId and filePath are required") try: connectionId, service, basePath = await _resolveDataSource(dsId) fullPath = filePath if filePath.startswith("/") else f"{basePath.rstrip('/')}/{filePath}" from modules.connectors.connectorResolver import ConnectorResolver resolver = ConnectorResolver( services.getService("security"), _buildResolverDb(), ) adapter = await resolver.resolveService(connectionId, service) fileBytes = await adapter.download(fullPath) if not fileBytes: return ToolResult(toolCallId="", toolName="downloadFromDataSource", success=False, error="Download returned empty") if not fileName or "." not in fileName: pathSegment = fullPath.split("/")[-1] or "downloaded_file" fileName = fileName or pathSegment if "." not in fileName: try: entries = await adapter.browse(basePath) for entry in entries: if getattr(entry, "path", "") == filePath or getattr(entry, "path", "").endswith(filePath): if "." in entry.name: fileName = entry.name break except Exception: pass if "." not in fileName: import mimetypes as _mt guessed = _mt.guess_type(f"file.{_mt.guess_extension('application/octet-stream') or ''}")[0] if not guessed and fileBytes[:4] == b"%PDF": fileName = f"{fileName}.pdf" elif not guessed and fileBytes[:2] == b"PK": fileName = f"{fileName}.zip" chatService = services.chat fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile(fileBytes, fileName) ext = fileName.rsplit(".", 1)[-1].lower() if "." in fileName else "" hint = "Use readFile to read the text content." if ext in ("doc", "docx", "txt", "csv", "json", "xml", "html", "md", "rtf", "odt", "xls", "xlsx", "pptx", "pdf") else "Use readFile to access the content." return ToolResult( toolCallId="", toolName="downloadFromDataSource", success=True, data=f"Downloaded '{fileName}' ({len(fileBytes)} bytes) → local file id: {fileItem.id}. {hint}" ) except Exception as e: return ToolResult(toolCallId="", toolName="downloadFromDataSource", success=False, error=str(e)) registry.register( "browseDataSource", _browseDataSource, description="Browse files AND folders in an ATTACHED data source by its dataSourceId. This is the PRIMARY tool for listing data source contents.", parameters={ "type": "object", "properties": { "dataSourceId": {"type": "string", "description": "DataSource ID (from the attached data sources in the prompt)"}, "subPath": {"type": "string", "description": "Optional sub-path within the data source to browse"}, "filter": {"type": "string", "description": "Optional filter pattern (e.g. '*.pdf')"}, }, "required": ["dataSourceId"], }, readOnly=True, ) registry.register( "searchDataSource", _searchDataSource, description="Search for files within an attached data source by query.", parameters={ "type": "object", "properties": { "dataSourceId": {"type": "string", "description": "DataSource ID"}, "query": {"type": "string", "description": "Search query"}, }, "required": ["dataSourceId", "query"], }, readOnly=True, ) registry.register( "downloadFromDataSource", _downloadFromDataSource, description="Download a file from an attached data source into local storage. Returns the local file ID which can then be read with readFile. Always provide the fileName if known from the browse results.", parameters={ "type": "object", "properties": { "dataSourceId": {"type": "string", "description": "DataSource ID"}, "filePath": {"type": "string", "description": "Path of the file to download (as returned by browseDataSource)"}, "fileName": {"type": "string", "description": "Human-readable file name with extension (e.g. 'report.pdf'). Get this from browseDataSource results."}, }, "required": ["dataSourceId", "filePath"], }, readOnly=False, ) # ---- Document tools (Smart Documents / Container Handling) ---- async def _browseContainer(args: Dict[str, Any], context: Dict[str, Any]): fileId = args.get("fileId", "") if not fileId: return ToolResult(toolCallId="", toolName="browseContainer", success=False, error="fileId is required") try: knowledgeService = services.getService("knowledge") index = knowledgeService.getFileContentIndex(fileId) if not index: return ToolResult(toolCallId="", toolName="browseContainer", success=True, data="No content index available for this file. It may not have been indexed yet.") structure = index.get("structure", {}) if isinstance(index, dict) else {} objectSummary = index.get("objectSummary", []) if isinstance(index, dict) else [] totalObjects = index.get("totalObjects", 0) if isinstance(index, dict) else 0 result = f"File: {index.get('fileName', '?')} ({index.get('mimeType', '?')})\n" result += f"Total content objects: {totalObjects}\n" sections = structure.get("sections", []) if sections: result += "\nSections:\n" for s in sections: result += f" [{s.get('id', '?')}] {s.get('title', 'Untitled')} (pages {s.get('startPage', '?')}-{s.get('endPage', '?')})\n" if structure.get("pageMap"): pages = len(structure["pageMap"]) result += f"\nPages: {pages}\n" imgCount = structure.get("imageCount", 0) tableCount = structure.get("tableCount", 0) if imgCount: result += f"Images: {imgCount}\n" if tableCount: result += f"Tables: {tableCount}\n" if structure.get("sheetMap"): result += "\nSheets:\n" for s in structure["sheetMap"]: result += f" {s.get('sheetName', '?')} ({s.get('rows', '?')} rows x {s.get('columns', '?')} cols)\n" if structure.get("slideMap"): result += "\nSlides:\n" for s in structure["slideMap"]: result += f" Slide {s.get('slideIndex', 0) + 1}: {s.get('title', '(no title)')}\n" return ToolResult(toolCallId="", toolName="browseContainer", success=True, data=result) except Exception as e: return ToolResult(toolCallId="", toolName="browseContainer", success=False, error=str(e)) async def _readContentObjects(args: Dict[str, Any], context: Dict[str, Any]): fileId = args.get("fileId", "") if not fileId: return ToolResult(toolCallId="", toolName="readContentObjects", success=False, error="fileId is required") try: knowledgeService = services.getService("knowledge") filterDict = {} if args.get("pageIndex") is not None: filterDict["pageIndex"] = args["pageIndex"] if args.get("contentType"): filterDict["contentType"] = args["contentType"] if args.get("sectionId"): filterDict["sectionId"] = args["sectionId"] objects = await knowledgeService.readContentObjects(fileId, filterDict) if not objects: return ToolResult(toolCallId="", toolName="readContentObjects", success=True, data="No content objects found with the given filter.") result = f"Found {len(objects)} content objects:\n\n" for obj in objects[:20]: data = obj.get("data", "") cType = obj.get("contentType", "?") ref = obj.get("contextRef", {}) location = ref.get("location", "") if isinstance(ref, dict) else "" preview = data[:300] if cType == "text" else f"[{cType} data, {len(data)} chars]" result += f"[{cType}] {location}: {preview}\n\n" if len(objects) > 20: result += f"... and {len(objects) - 20} more objects" return ToolResult(toolCallId="", toolName="readContentObjects", success=True, data=result) except Exception as e: return ToolResult(toolCallId="", toolName="readContentObjects", success=False, error=str(e)) async def _extractContainerItem(args: Dict[str, Any], context: Dict[str, Any]): fileId = args.get("fileId", "") containerPath = args.get("containerPath", "") if not fileId or not containerPath: return ToolResult(toolCallId="", toolName="extractContainerItem", success=False, error="fileId and containerPath are required") try: knowledgeService = services.getService("knowledge") result = await knowledgeService.extractContainerItem(fileId, containerPath) if result: return ToolResult(toolCallId="", toolName="extractContainerItem", success=True, data=str(result)) return ToolResult(toolCallId="", toolName="extractContainerItem", success=True, data=f"On-demand extraction for '{containerPath}' queued.") except Exception as e: return ToolResult(toolCallId="", toolName="extractContainerItem", success=False, error=str(e)) async def _summarizeContent(args: Dict[str, Any], context: Dict[str, Any]): fileId = args.get("fileId", "") if not fileId: return ToolResult(toolCallId="", toolName="summarizeContent", success=False, error="fileId is required") try: knowledgeService = services.getService("knowledge") filterDict = {} if args.get("sectionId"): filterDict["sectionId"] = args["sectionId"] if args.get("pageIndex") is not None: filterDict["pageIndex"] = args["pageIndex"] if args.get("contentType"): filterDict["contentType"] = args["contentType"] objects = await knowledgeService.readContentObjects(fileId, filterDict) if not objects: return ToolResult(toolCallId="", toolName="summarizeContent", success=True, data="No content found to summarize.") textParts = [obj.get("data", "") for obj in objects if obj.get("contentType") != "image"] combinedText = "\n\n".join(textParts)[:6000] aiService = services.ai from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum summaryRequest = AiCallRequest( prompt=f"Summarize the following content concisely:\n\n{combinedText}", options=AiCallOptions(operationType=OperationTypeEnum.DATA_ANALYSE), ) response = await aiService.callAi(summaryRequest) return ToolResult(toolCallId="", toolName="summarizeContent", success=True, data=response.content) except Exception as e: return ToolResult(toolCallId="", toolName="summarizeContent", success=False, error=str(e)) registry.register( "browseContainer", _browseContainer, description="Browse the structural index of a file/container (pages, sections, sheets, slides).", parameters={ "type": "object", "properties": {"fileId": {"type": "string", "description": "The file ID to browse"}}, "required": ["fileId"], }, readOnly=True, ) registry.register( "readContentObjects", _readContentObjects, description="Read content objects from a file with optional filters (page, section, type).", parameters={ "type": "object", "properties": { "fileId": {"type": "string", "description": "The file ID"}, "pageIndex": {"type": "integer", "description": "Filter by page index"}, "sectionId": {"type": "string", "description": "Filter by section ID"}, "contentType": {"type": "string", "description": "Filter by content type (text, image, etc.)"}, }, "required": ["fileId"], }, readOnly=True, ) registry.register( "extractContainerItem", _extractContainerItem, description="On-demand extraction of a specific item within a container (ZIP, nested file).", parameters={ "type": "object", "properties": { "fileId": {"type": "string", "description": "The container file ID"}, "containerPath": {"type": "string", "description": "Path within the container"}, }, "required": ["fileId", "containerPath"], }, readOnly=True, ) registry.register( "summarizeContent", _summarizeContent, description="AI-powered summary of content objects from a file, optionally filtered.", parameters={ "type": "object", "properties": { "fileId": {"type": "string", "description": "The file ID"}, "sectionId": {"type": "string", "description": "Optional: summarize only this section"}, "pageIndex": {"type": "integer", "description": "Optional: summarize only this page"}, "contentType": {"type": "string", "description": "Optional: filter by content type"}, }, "required": ["fileId"], }, readOnly=True, ) # ---- Vision tool ---- async def _describeImage(args: Dict[str, Any], context: Dict[str, Any]): """Analyse an image using AI vision. Uses Knowledge Store chunks produced by Extractors.""" fileId = args.get("fileId", "") prompt = args.get("prompt", "Describe this image in detail. Extract all visible text, tables, and data.") pageIndex = args.get("pageIndex") if not fileId: return ToolResult(toolCallId="", toolName="describeImage", success=False, error="fileId is required") try: import base64 as _b64 imageData = None mimeType = "image/png" knowledgeService = services.getService("knowledge") if hasattr(services, "getService") else None # 1) Knowledge Store: image chunks already produced by PdfExtractor / ImageExtractor if knowledgeService: chunks = knowledgeService._knowledgeDb.getContentChunks(fileId) imageChunks = [c for c in (chunks or []) if c.get("contentType") == "image"] if pageIndex is not None: imageChunks = [c for c in imageChunks if c.get("contextRef", {}).get("pageIndex") == pageIndex] if imageChunks: imageData = imageChunks[0].get("data", "") chunkMime = imageChunks[0].get("contextRef", {}).get("mimeType") if chunkMime: mimeType = chunkMime # 2) File not yet indexed -> trigger extraction via ExtractionService, then retry if not imageData and knowledgeService and not knowledgeService.isFileIndexed(fileId): try: chatService = services.chat fileInfo = chatService.getFileInfo(fileId) fileContent = chatService.getFileContent(fileId) if fileContent and fileInfo: rawData = fileContent.get("data", "") if isinstance(rawData, str) and len(rawData) > 100: rawBytes = _b64.b64decode(rawData) elif isinstance(rawData, bytes): rawBytes = rawData else: rawBytes = None if rawBytes: from modules.serviceCenter.services.serviceExtraction.subRegistry import ExtractorRegistry from modules.serviceCenter.services.serviceExtraction.subPipeline import runExtraction from modules.datamodels.datamodelExtraction import ExtractionOptions fileMime = fileInfo.get("mimeType", "application/octet-stream") fileName = fileInfo.get("fileName", fileId) extracted = runExtraction( ExtractorRegistry(), None, rawBytes, fileName, fileMime, ExtractionOptions(), ) contentObjects = [] for part in extracted.parts: tg = (part.typeGroup or "").lower() ct = "image" if tg == "image" else "text" if not part.data or not part.data.strip(): continue contentObjects.append({ "contentObjectId": part.id, "contentType": ct, "data": part.data, "contextRef": {"containerPath": fileName, "location": part.label, **(part.metadata or {})}, }) if contentObjects: await knowledgeService.indexFile( fileId=fileId, fileName=fileName, mimeType=fileMime, userId=context.get("userId", ""), contentObjects=contentObjects, ) chunks = knowledgeService._knowledgeDb.getContentChunks(fileId) imageChunks = [c for c in (chunks or []) if c.get("contentType") == "image"] if pageIndex is not None: imageChunks = [c for c in imageChunks if c.get("contextRef", {}).get("pageIndex") == pageIndex] if imageChunks: imageData = imageChunks[0].get("data", "") except Exception as extractErr: logger.warning(f"describeImage: on-demand extraction failed: {extractErr}") # 3) Direct image file (not a container) - use raw file data if not imageData: chatService = services.chat fileContent = chatService.getFileContent(fileId) if fileContent: fileMimeType = fileContent.get("mimeType", "") if fileMimeType.startswith("image/"): imageData = fileContent.get("data", "") mimeType = fileMimeType if not imageData: chatService = services.chat fileInfo = chatService.getFileInfo(fileId) if hasattr(chatService, "getFileInfo") else None fileName = fileInfo.get("fileName", fileId) if fileInfo else fileId fileMime = fileInfo.get("mimeType", "unknown") if fileInfo else "unknown" return ToolResult(toolCallId="", toolName="describeImage", success=False, error=f"No image data found in '{fileName}' (type: {fileMime}). " f"This file likely contains text, not images. Use readFile(fileId=\"{fileId}\") to access its text content.") try: rawHead = _b64.b64decode(imageData[:32]) if rawHead[:3] == b"\xff\xd8\xff": mimeType = "image/jpeg" elif rawHead[:8] == b"\x89PNG\r\n\x1a\n": mimeType = "image/png" elif rawHead[:4] == b"GIF8": mimeType = "image/gif" elif rawHead[:4] == b"RIFF" and rawHead[8:12] == b"WEBP": mimeType = "image/webp" except Exception: pass dataUrl = f"data:{mimeType};base64,{imageData}" from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum as OTE visionRequest = AiCallRequest( prompt=prompt, options=AiCallOptions(operationType=OTE.IMAGE_ANALYSE), messages=[{"role": "user", "content": [ {"type": "text", "text": prompt}, {"type": "image_url", "image_url": {"url": dataUrl}}, ]}], ) visionResponse = await services.ai.callAi(visionRequest) if visionResponse.errorCount > 0: return ToolResult(toolCallId="", toolName="describeImage", success=False, error=visionResponse.content) return ToolResult(toolCallId="", toolName="describeImage", success=True, data=visionResponse.content) except Exception as e: return ToolResult(toolCallId="", toolName="describeImage", success=False, error=str(e)) registry.register( "describeImage", _describeImage, description="Analyse an image using AI vision. Works with image files and images extracted from PDFs/DOCX/PPTX.", parameters={ "type": "object", "properties": { "fileId": {"type": "string", "description": "The file ID containing the image or document with images"}, "prompt": {"type": "string", "description": "What to look for in the image (default: describe everything)"}, "pageIndex": {"type": "integer", "description": "Filter images by page index (0-based, for multi-page documents)"}, }, "required": ["fileId"], }, readOnly=True, ) # ---- Document rendering tool ---- def _markdownToDocumentJson(markdown: str, title: str, language: str = "de") -> Dict[str, Any]: """Convert markdown content to the standard document JSON format expected by renderers.""" import re as _re sections = [] order = 0 lines = markdown.split("\n") i = 0 def _nextId(): nonlocal order order += 1 return f"s_{order}" while i < len(lines): line = lines[i] # --- Headings --- headingMatch = _re.match(r'^(#{1,6})\s+(.+)', line) if headingMatch: level = len(headingMatch.group(1)) text = headingMatch.group(2).strip() sections.append({ "id": _nextId(), "content_type": "heading", "order": order, "elements": [{"content": {"text": text, "level": level}}], }) i += 1 continue # --- Fenced code blocks --- codeMatch = _re.match(r'^```(\w*)', line) if codeMatch: lang = codeMatch.group(1) or "text" codeLines = [] i += 1 while i < len(lines) and not lines[i].startswith("```"): codeLines.append(lines[i]) i += 1 i += 1 sections.append({ "id": _nextId(), "content_type": "code_block", "order": order, "elements": [{"content": {"code": "\n".join(codeLines), "language": lang}}], }) continue # --- Tables --- tableMatch = _re.match(r'^\|(.+)\|$', line) if tableMatch and (i + 1) < len(lines) and _re.match(r'^\|[\s\-:|]+\|$', lines[i + 1]): headerCells = [c.strip() for c in tableMatch.group(1).split("|")] i += 2 rows = [] while i < len(lines) and _re.match(r'^\|(.+)\|$', lines[i]): rowCells = [c.strip() for c in lines[i][1:-1].split("|")] rows.append(rowCells) i += 1 sections.append({ "id": _nextId(), "content_type": "table", "order": order, "elements": [{"content": {"headers": headerCells, "rows": rows}}], }) continue # --- Bullet / numbered lists --- listMatch = _re.match(r'^(\s*)([-*+]|\d+[.)]) (.+)', line) if listMatch: isNumbered = bool(_re.match(r'\d+[.)]', listMatch.group(2))) items = [] while i < len(lines) and _re.match(r'^(\s*)([-*+]|\d+[.)]) (.+)', lines[i]): m = _re.match(r'^(\s*)([-*+]|\d+[.)]) (.+)', lines[i]) items.append({"text": m.group(3).strip()}) i += 1 sections.append({ "id": _nextId(), "content_type": "bullet_list", "order": order, "elements": [{"content": {"items": items, "list_type": "numbered" if isNumbered else "bullet"}}], }) continue # --- Empty lines (skip) --- if not line.strip(): i += 1 continue # --- Images: ![alt](file:fileId) or ![alt](url) --- imgMatch = _re.match(r'^!\[([^\]]*)\]\(([^)]+)\)', line) if imgMatch: altText = imgMatch.group(1).strip() or "Image" src = imgMatch.group(2).strip() fileId = "" if src.startswith("file:"): fileId = src[5:] sections.append({ "id": _nextId(), "content_type": "image", "order": order, "elements": [{ "content": { "altText": altText, "base64Data": "", "_fileRef": fileId, "_srcUrl": src if not fileId else "", } }], }) i += 1 continue # --- Paragraph (collect consecutive non-empty lines) --- paraLines = [] while i < len(lines) and lines[i].strip() and not _re.match(r'^(#{1,6}\s|```|\|.+\||!\[|(\s*)([-*+]|\d+[.)]) )', lines[i]): paraLines.append(lines[i]) i += 1 if paraLines: sections.append({ "id": _nextId(), "content_type": "paragraph", "order": order, "elements": [{"content": {"text": " ".join(paraLines)}}], }) continue i += 1 if not sections: sections.append({ "id": _nextId(), "content_type": "paragraph", "order": order, "elements": [{"content": {"text": markdown.strip() or "(empty)"}}], }) return { "metadata": { "split_strategy": "single_document", "source_documents": [], "extraction_method": "agent_rendering", "title": title, "language": language, }, "documents": [{ "id": "doc_1", "title": title, "sections": sections, }], } async def _renderDocument(args: Dict[str, Any], context: Dict[str, Any]): """Render agent-produced markdown content into any document format via the RendererRegistry.""" import re as _re content = args.get("content", "") outputFormat = args.get("outputFormat", "pdf") title = args.get("title", "Document") language = args.get("language", "de") if not content: return ToolResult(toolCallId="", toolName="renderDocument", success=False, error="content is required") try: structuredContent = _markdownToDocumentJson(content, title, language) # Resolve image file references (file:fileId) to base64 data from Knowledge Store knowledgeService = None try: knowledgeService = services.getService("knowledge") except Exception: pass resolvedImages = 0 for doc in structuredContent.get("documents", []): for section in doc.get("sections", []): if section.get("content_type") != "image": continue for element in section.get("elements", []): contentObj = element.get("content", {}) fileRef = contentObj.get("_fileRef", "") if not fileRef or contentObj.get("base64Data"): continue if knowledgeService: chunks = knowledgeService._knowledgeDb.getContentChunks(fileRef) imageChunks = [c for c in (chunks or []) if c.get("contentType") == "image"] if imageChunks: contentObj["base64Data"] = imageChunks[0].get("data", "") chunkMime = imageChunks[0].get("contextRef", {}).get("mimeType", "image/png") contentObj["mimeType"] = chunkMime resolvedImages += 1 if not contentObj.get("base64Data"): try: rawBytes = services.chat.getFileData(fileRef) if rawBytes: import base64 as _b64 contentObj["base64Data"] = _b64.b64encode(rawBytes).decode("ascii") contentObj["mimeType"] = "image/png" resolvedImages += 1 except Exception: pass contentObj.pop("_fileRef", None) contentObj.pop("_srcUrl", None) sectionCount = len(structuredContent.get("documents", [{}])[0].get("sections", [])) logger.info(f"renderDocument: parsed {sectionCount} sections from markdown ({len(content)} chars), resolved {resolvedImages} image(s), format={outputFormat}") generationService = services.getService("generation") documents = await generationService.renderReport( extractedContent=structuredContent, outputFormat=outputFormat, language=language, title=title, userPrompt=content, ) if not documents: return ToolResult(toolCallId="", toolName="renderDocument", success=False, error="Rendering produced no output") savedFiles = [] sideEvents = [] chatService = services.chat sanitizedTitle = _re.sub(r'[^\w._-]', '_', title, flags=_re.UNICODE).strip('_') or "document" for doc in documents: docData = doc.documentData if hasattr(doc, "documentData") else b"" docName = doc.filename if hasattr(doc, "filename") else f"{sanitizedTitle}.{outputFormat}" docMime = doc.mimeType if hasattr(doc, "mimeType") else "application/octet-stream" if not docName.lower().endswith(f".{outputFormat}"): docName = f"{sanitizedTitle}.{outputFormat}" fileItem = None if hasattr(chatService.interfaceDbComponent, "saveGeneratedFile"): fileItem = chatService.interfaceDbComponent.saveGeneratedFile(docData, docName, docMime) else: fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile(docData, docName) if fileItem: fid = fileItem.id if hasattr(fileItem, "id") else fileItem.get("id", "?") fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "") if fiId: chatService.interfaceDbComponent.updateFile(fid, {"featureInstanceId": fiId}) savedFiles.append(f"- {docName} (id: {fid})") sideEvents.append({ "type": "fileCreated", "data": { "fileId": fid, "fileName": docName, "mimeType": docMime, "fileSize": len(docData), }, }) result = f"Rendered {len(documents)} document(s):\n" + "\n".join(savedFiles) return ToolResult(toolCallId="", toolName="renderDocument", success=True, data=result, sideEvents=sideEvents) except Exception as e: logger.error(f"renderDocument failed: {e}") return ToolResult(toolCallId="", toolName="renderDocument", success=False, error=str(e)) registry.register( "renderDocument", _renderDocument, description=( "Render markdown content into a document file (PDF, DOCX, XLSX, PPTX, CSV, HTML, MD, JSON, TXT). " "You write the full document content as markdown, then this tool converts and renders it. " "To embed images from uploaded files, use markdown image syntax with the file ID: ![alt text](file:fileId). " "The images will be resolved from the Knowledge Store and embedded in the output document." ), parameters={ "type": "object", "properties": { "content": {"type": "string", "description": "Full document content as markdown (headings, tables, lists, code blocks, paragraphs, images via ![alt](file:fileId))"}, "outputFormat": {"type": "string", "description": "Target format: pdf, docx, xlsx, pptx, csv, html, md, json, txt", "default": "pdf"}, "title": {"type": "string", "description": "Document title", "default": "Document"}, "language": {"type": "string", "description": "Document language (ISO 639-1)", "default": "de"}, }, "required": ["content"], }, readOnly=False, ) # ── textToSpeech tool ────────────────────────────────────────────── def _stripMarkdownForTts(text: str) -> str: """Strip markdown formatting so TTS reads clean speech text.""" import re as _re t = text t = _re.sub(r'\*\*(.+?)\*\*', r'\1', t) t = _re.sub(r'\*(.+?)\*', r'\1', t) t = _re.sub(r'__(.+?)__', r'\1', t) t = _re.sub(r'_(.+?)_', r'\1', t) t = _re.sub(r'`[^`]+`', lambda m: m.group(0)[1:-1], t) t = _re.sub(r'^#{1,6}\s*', '', t, flags=_re.MULTILINE) t = _re.sub(r'^\s*[-*+]\s+', '', t, flags=_re.MULTILINE) t = _re.sub(r'^\s*\d+\.\s+', '', t, flags=_re.MULTILINE) t = _re.sub(r'\[(.+?)\]\(.+?\)', r'\1', t) t = _re.sub(r'!\[.*?\]\(.*?\)', '', t) t = _re.sub(r'\n{3,}', '\n\n', t) return t.strip() async def _textToSpeech(args: Dict[str, Any], context: Dict[str, Any]): """Convert text to speech using Google Cloud TTS, deliver audio via SSE.""" import base64 as _b64 text = args.get("text", "") language = args.get("language", "auto") voiceName = args.get("voiceName") if not text: return ToolResult(toolCallId="", toolName="textToSpeech", success=False, error="text is required") cleanText = _stripMarkdownForTts(text) if not cleanText: return ToolResult(toolCallId="", toolName="textToSpeech", success=False, error="text is empty after stripping markdown") try: from modules.interfaces.interfaceVoiceObjects import getVoiceInterface mandateId = context.get("mandateId", "") voiceInterface = getVoiceInterface(currentUser=None, mandateId=mandateId) _ISO_TO_BCP47 = { "de": "de-DE", "en": "en-US", "fr": "fr-FR", "it": "it-IT", "es": "es-ES", "pt": "pt-BR", "nl": "nl-NL", "pl": "pl-PL", "ru": "ru-RU", "ja": "ja-JP", "zh": "zh-CN", "ko": "ko-KR", "ar": "ar-XA", "hi": "hi-IN", "tr": "tr-TR", "sv": "sv-SE", } if language == "auto": try: snippet = cleanText[:500] detectResult = await voiceInterface.detectLanguage(snippet) if detectResult and detectResult.get("success"): detected = detectResult.get("language", "de") language = _ISO_TO_BCP47.get(detected, detected) if "-" not in language: language = _ISO_TO_BCP47.get(language, f"{language}-{language.upper()}") logger.info(f"textToSpeech: auto-detected language '{detected}' -> '{language}'") else: language = "de-DE" except Exception as detectErr: logger.warning(f"textToSpeech: language detection failed: {detectErr}, defaulting to de-DE") language = "de-DE" if not voiceName: try: featureInstanceId = context.get("featureInstanceId", "") userId = context.get("userId", "") if featureInstanceId and userId: dbMgmt = services.chat.interfaceDbApp if hasattr(services.chat, "interfaceDbApp") else None if dbMgmt and hasattr(dbMgmt, "getVoiceSettings"): vs = dbMgmt.getVoiceSettings(userId) if vs: voiceMap = {} if hasattr(vs, "ttsVoiceMap") and vs.ttsVoiceMap: voiceMap = vs.ttsVoiceMap if isinstance(vs.ttsVoiceMap, dict) else {} if language in voiceMap: voiceName = voiceMap[language].get("voiceName") if isinstance(voiceMap[language], dict) else voiceMap[language] logger.info(f"textToSpeech: using configured voice '{voiceName}' for {language}") elif hasattr(vs, "ttsVoice") and vs.ttsVoice and hasattr(vs, "ttsLanguage") and vs.ttsLanguage == language: voiceName = vs.ttsVoice except Exception as prefErr: logger.debug(f"textToSpeech: could not load voice preferences: {prefErr}") ttsResult = await voiceInterface.textToSpeech( text=cleanText, languageCode=language, voiceName=voiceName, ) if not ttsResult or not ttsResult.get("success"): errMsg = ttsResult.get("error", "TTS call failed") if ttsResult else "TTS returned None" return ToolResult(toolCallId="", toolName="textToSpeech", success=False, error=errMsg) audioContent = ttsResult.get("audioContent", "") if not audioContent: return ToolResult(toolCallId="", toolName="textToSpeech", success=False, error="TTS returned no audio") if isinstance(audioContent, bytes): audioB64 = _b64.b64encode(audioContent).decode("ascii") elif isinstance(audioContent, str): audioB64 = audioContent else: audioB64 = str(audioContent) audioFormat = ttsResult.get("audioFormat", "mp3") charCount = len(cleanText) usedVoice = voiceName or "default" logger.info(f"textToSpeech: generated {audioFormat} audio for {charCount} chars, language={language}, voice={usedVoice}") return ToolResult( toolCallId="", toolName="textToSpeech", success=True, data=f"Audio generated ({charCount} characters, language={language}, voice={usedVoice}). Playing in chat.", sideEvents=[{ "type": "voiceResponse", "data": { "audio": audioB64, "format": audioFormat, "language": language, "charCount": charCount, }, }], ) except ImportError: return ToolResult(toolCallId="", toolName="textToSpeech", success=False, error="Voice interface not available (missing dependency)") except Exception as e: logger.error(f"textToSpeech failed: {e}") return ToolResult(toolCallId="", toolName="textToSpeech", success=False, error=str(e)) registry.register( "textToSpeech", _textToSpeech, description=( "Convert text to speech audio. The audio is played directly in the chat. " "Use this when the user asks you to read something aloud, narrate, or speak. " "Language is auto-detected from the text content. You do NOT need to specify a language." ), parameters={ "type": "object", "properties": { "text": {"type": "string", "description": "The text to convert to speech. Can include markdown (will be stripped automatically)."}, "language": {"type": "string", "description": "BCP-47 language code (e.g. de-DE, en-US) or 'auto' for automatic detection", "default": "auto"}, "voiceName": {"type": "string", "description": "Optional specific voice name. If omitted, uses the configured voice for the detected language."}, }, "required": ["text"], }, readOnly=False, ) # ── generateImage tool ───────────────────────────────────────────── async def _generateImage(args: Dict[str, Any], context: Dict[str, Any]): """Generate an image from a text prompt using AI (DALL-E).""" import re as _re prompt = (args.get("prompt") or "").strip() style = (args.get("style") or "").strip() or None title = (args.get("title") or "").strip() or "Generated Image" if not prompt: return ToolResult(toolCallId="", toolName="generateImage", success=False, error="prompt is required") try: from modules.serviceCenter.services.serviceGeneration.paths.imagePath import ImageGenerationPath imagePath = ImageGenerationPath(services) aiResponse = await imagePath.generateImages( userPrompt=prompt, count=1, style=style, format="png", title=title, ) if not aiResponse.documents: return ToolResult(toolCallId="", toolName="generateImage", success=False, error="Image generation returned no image data") sideEvents = [] savedFiles = [] chatService = services.chat sanitizedTitle = _re.sub(r'[^\w._-]', '_', title, flags=_re.UNICODE).strip('_') or "generated_image" for doc in aiResponse.documents: docData = doc.documentData if hasattr(doc, "documentData") else b"" docName = doc.documentName if hasattr(doc, "documentName") else f"{sanitizedTitle}.png" docMime = doc.mimeType if hasattr(doc, "mimeType") else "image/png" if not docName.lower().endswith(".png"): docName = f"{sanitizedTitle}.png" fileItem = None if hasattr(chatService.interfaceDbComponent, "saveGeneratedFile"): fileItem = chatService.interfaceDbComponent.saveGeneratedFile(docData, docName, docMime) else: fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile(docData, docName) if fileItem: fid = fileItem.id if hasattr(fileItem, "id") else fileItem.get("id", "?") fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "") if fiId: chatService.interfaceDbComponent.updateFile(fid, {"featureInstanceId": fiId}) savedFiles.append(f"- {docName} (id: {fid})") sideEvents.append({ "type": "fileCreated", "data": { "fileId": fid, "fileName": docName, "mimeType": docMime, "fileSize": len(docData), }, }) result = f"Generated {len(aiResponse.documents)} image(s):\n" + "\n".join(savedFiles) return ToolResult(toolCallId="", toolName="generateImage", success=True, data=result, sideEvents=sideEvents) except Exception as e: logger.error(f"generateImage failed: {e}") return ToolResult(toolCallId="", toolName="generateImage", success=False, error=str(e)) registry.register( "generateImage", _generateImage, description=( "Generate an image from a text description using AI (DALL-E). " "The generated image is saved as a file in the workspace. " "Use this when the user asks to create, generate, draw, or design an image, illustration, icon, logo, diagram, or any visual content. " "Provide a detailed, descriptive prompt for best results." ), parameters={ "type": "object", "properties": { "prompt": {"type": "string", "description": "Detailed description of the image to generate. Be specific about subject, composition, colors, style, and mood."}, "style": {"type": "string", "description": "Optional style modifier (e.g. 'photorealistic', 'watercolor', 'digital art', 'minimalist', 'sketch')"}, "title": {"type": "string", "description": "Title/filename for the generated image", "default": "Generated Image"}, }, "required": ["prompt"], }, readOnly=False, )