# 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 = "",
        conversationHistory: List[Dict[str, Any]] = None,
    ) -> 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
            conversationHistory: Prior messages for follow-up context

        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()
        persistRoundMemoryFn = self._createPersistRoundMemoryFn(workflowId)
        getExternalMemoryKeysFn = self._createGetExternalMemoryKeysFn(workflowId)

        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,
            conversationHistory=conversationHistory,
            persistRoundMemoryFn=persistRoundMemoryFn,
            getExternalMemoryKeysFn=getExternalMemoryKeysFn,
        ):
            if event.type == AgentEventTypeEnum.AGENT_SUMMARY:
                await self._persistTrace(workflowId, event.data or {})
            if event.type != AgentEventTypeEnum.CHUNK:
                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)

                    if not info:
                        folderInfo = chatService.interfaceDbComponent.getFolder(fid)
                        if folderInfo:
                            folderName = folderInfo.get("name", fid)
                            folderFiles = chatService.listFiles(folderId=fid)
                            desc = f"### Folder: {folderName}\n  - id: {fid}\n  - type: folder\n  - contains: {len(folderFiles)} file(s)"
                            if folderFiles:
                                desc += "\n  - files:"
                                for ff in folderFiles[:30]:
                                    ffName = ff.get("fileName", "?")
                                    ffId = ff.get("id", "?")
                                    ffMime = ff.get("mimeType", "?")
                                    ffSize = ff.get("fileSize", ff.get("size", "?"))
                                    desc += f"\n    * {ffName} (id: {ffId}, type: {ffMime}, size: {ffSize} bytes)"
                                if len(folderFiles) > 30:
                                    desc += f"\n    ... and {len(folderFiles) - 30} more files"
                            desc += f'\nUse `listFiles(folderId="{fid}")` to get the full file list, then `readFile(fileId)` to read individual files.'
                            fileDescriptions.append(desc)
                            continue
                        fileDescriptions.append(f"### File id: {fid}")
                        continue

                    fileName = info.get("fileName", fid)
                    mimeType = info.get("mimeType", "unknown")
                    fileSize = info.get("size", "?")

                    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 & Folders\n"
                    "These files/folders 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"
                    "For folders, use `listFiles(folderId)` to get the files inside, then `readFile(fileId)` for each.\n"
                    "For large PDFs/DOCX, avoid huge `renderDocument` tool JSON: build markdown with "
                    "`writeFile` (create + append), then `renderDocument(sourceFileId=that file id, outputFormat=...)`.\n"
                    "For small docs you may pass `content` inline. Embed images with `![alt](file:fileId)` in markdown.\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 and workflow artifacts 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",
            )

            artifacts = summaryData.get("artifacts", "")
            if artifacts:
                await knowledgeService.storeEntity(
                    workflowId=workflowId,
                    userId=userId,
                    featureInstanceId=featureInstanceId,
                    key="_workflowArtifacts",
                    value=artifacts,
                    source="agent",
                )
                logger.info(f"Persisted workflow artifacts for workflow {workflowId}")

            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")
                workflowHintItems = _buildWorkflowHintItems(
                    self.services, workflowId
                )
                return await knowledgeService.buildAgentContext(
                    currentPrompt=currentPrompt,
                    workflowId=workflowId,
                    userId=userId,
                    featureInstanceId=featureInstanceId,
                    mandateId=mandateId,
                    workflowHintItems=workflowHintItems,
                    isSysAdmin=getattr(self.services.user, "isSysAdmin", False),
                )
            except Exception as e:
                logger.debug(f"RAG context not available: {e}")
                return ""
        return _buildRagContext

    def _createPersistRoundMemoryFn(self, workflowId: str):
        """Create callback that persists RoundMemory entries after tool execution."""
        from modules.serviceCenter.services.serviceAgent.agentLoop import _classifyToolResult
        from modules.datamodels.datamodelKnowledge import RoundMemory

        async def _persistRoundMemory(
            toolCalls, results, textContent: str, roundNumber: int
        ):
            try:
                knowledgeService = self.services.getService("knowledge")
            except Exception:
                return
            knowledgeDb = knowledgeService._knowledgeDb

            for tc, result in zip(toolCalls, results):
                if not result.success:
                    continue
                classified = _classifyToolResult(tc, result)
                if not classified:
                    continue

                summary = classified["summary"]
                embedding = await knowledgeService._embedSingle(summary[:500]) if summary else []

                mem = RoundMemory(
                    workflowId=workflowId,
                    roundNumber=roundNumber,
                    memoryType=classified["memoryType"],
                    key=classified["key"],
                    summary=summary,
                    fullData=classified.get("fullData"),
                    fileIds=classified.get("fileIds", []),
                    embedding=embedding if embedding else None,
                )
                knowledgeDb.storeRoundMemory(mem)

        return _persistRoundMemory

    def _createGetExternalMemoryKeysFn(self, workflowId: str):
        """Create callback that returns RoundMemory keys for summarization hints."""
        def _getKeys() -> List[str]:
            try:
                knowledgeService = self.services.getService("knowledge")
                memories = knowledgeService._knowledgeDb.getRoundMemories(workflowId)
                return [m.get("key", "") for m in memories if m.get("key")]
            except Exception:
                return []
        return _getKeys


def _buildWorkflowHintItems(
    services, currentWorkflowId: str
) -> List[Dict[str, Any]]:
    """Build a compact list of other workflows for the RAG cross-workflow hint.

    Returns key-value items like:
        key="Pendenzenliste Excel (3 msgs)"  value="last: 2h ago"
    Limited to 10 most recent other workflows to keep the hint small.
    """
    try:
        chatInterface = services.chat.interfaceDbChat
        allWorkflows = chatInterface.getWorkflows() or []
    except Exception:
        return []

    others = [w for w in allWorkflows if w.get("id") != currentWorkflowId]
    if not others:
        return []

    import time as _time
    now = _time.time()
    others.sort(key=lambda w: w.get("sysCreatedAt") or w.get("startedAt") or 0, reverse=True)
    others = others[:10]

    items = []
    for wf in others:
        name = wf.get("name") or "(unnamed)"
        createdAt = wf.get("sysCreatedAt") or wf.get("startedAt") or 0
        ageSec = now - createdAt if createdAt else 0
        if ageSec < 3600:
            ageStr = f"{int(ageSec / 60)}m ago"
        elif ageSec < 86400:
            ageStr = f"{int(ageSec / 3600)}h ago"
        else:
            ageStr = f"{int(ageSec / 86400)}d ago"

        wfId = wf.get("id", "")
        items.append({
            "key": f"{name} (id: {wfId})",
            "value": ageStr,
        })

    countLabel = f"{len(allWorkflows) - 1} other conversation(s)"
    if len(allWorkflows) - 1 > 10:
        countLabel += f" (showing 10 newest)"
    items.insert(0, {"key": countLabel, "value": "use listWorkflowHistory to browse"})
    return items


def _getOrCreateTempFolder(chatService) -> Optional[str]:
    """Return the ID of the root-level 'Temp' folder, creating it if it doesn't exist."""
    try:
        allFolders = chatService.interfaceDbComponent.listFolders()
        tempFolder = next(
            (f for f in allFolders
             if f.get("name") == "Temp" and not f.get("parentId")),
            None,
        )
        if tempFolder:
            return tempFolder.get("id")
        newFolder = chatService.interfaceDbComponent.createFolder("Temp", parentId=None)
        return newFolder.get("id") if newFolder else None
    except Exception as e:
        logger.warning(f"Could not get/create Temp folder: {e}")
        return None


def _registerCoreTools(registry: ToolRegistry, services):
    """Register built-in core tools: file operations, search, and folder management."""
    import uuid as _uuid
    from modules.serviceCenter.services.serviceAgent.datamodelAgent import ToolResult

    # ---- Read-only tools ----

    def _applyOffsetLimit(text: str, offset: int = None, limit: int = None) -> str:
        """Apply line-based offset/limit to text content, returning numbered lines."""
        if offset is None and limit is None:
            return None
        lines = text.split("\n")
        totalLines = len(lines)
        startLine = max(0, (offset or 1) - 1)
        endLine = min(totalLines, startLine + (limit or 200))
        selected = lines[startLine:endLine]
        numbered = "\n".join(f"{i + startLine + 1}|{line}" for i, line in enumerate(selected))
        header = f"[Lines {startLine + 1}-{endLine} of {totalLines} total]\n"
        return header + numbered

    async def _readFile(args: Dict[str, Any], context: Dict[str, Any]):
        fileId = args.get("fileId", "")
        offset = args.get("offset")
        limit = args.get("limit")
        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)
                        chunked = _applyOffsetLimit(assembled, offset, limit)
                        if chunked is not None:
                            return ToolResult(toolCallId="", toolName="readFile", success=True, data=chunked)
                        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)}. Use offset/limit to read specific sections.]"
                        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",
                             "message/rfc822")
            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)
                            chunked = _applyOffsetLimit(joined, offset, limit)
                            if chunked is not None:
                                return ToolResult(toolCallId="", toolName="readFile", success=True, data=chunked)
                            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)}. Use offset/limit to read specific sections.]"
                            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():
                        chunked = _applyOffsetLimit(text, offset, limit)
                        if chunked is not None:
                            return ToolResult(toolCallId="", toolName="readFile", success=True, data=chunked)
                        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)}. Use offset/limit to read specific sections.]"
                        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 _searchInFileContent(args: Dict[str, Any], context: Dict[str, Any]):
        import re as _re
        fileId = args.get("fileId", "")
        query = args.get("query", "")
        contextLines = args.get("contextLines", 2)
        if not fileId or not query:
            return ToolResult(toolCallId="", toolName="searchInFileContent", success=False, error="fileId and query are required")
        try:
            chatService = services.chat
            rawBytes = chatService.getFileData(fileId)
            if not rawBytes:
                return ToolResult(toolCallId="", toolName="searchInFileContent", success=False, error="File data not accessible")
            try:
                content = rawBytes.decode("utf-8")
            except UnicodeDecodeError:
                content = rawBytes.decode("latin-1", errors="replace")

            lines = content.split("\n")
            pattern = _re.compile(_re.escape(query), _re.IGNORECASE)
            matches = []
            for i, line in enumerate(lines):
                if pattern.search(line):
                    start = max(0, i - contextLines)
                    end = min(len(lines), i + contextLines + 1)
                    snippet = "\n".join(f"{j + 1}|{lines[j]}" for j in range(start, end))
                    matches.append(snippet)

            if not matches:
                return ToolResult(toolCallId="", toolName="searchInFileContent", success=True,
                                  data=f"No matches for '{query}' in file.")

            shown = matches[:20]
            resultText = f"Found {len(matches)} match(es) for '{query}':\n\n" + "\n---\n".join(shown)
            if len(matches) > 20:
                resultText += f"\n\n... and {len(matches) - 20} more matches"
            return ToolResult(toolCallId="", toolName="searchInFileContent", success=True, data=resultText)
        except Exception as e:
            return ToolResult(toolCallId="", toolName="searchInFileContent", 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]):
        content = args.get("content", "")
        mode = args.get("mode", "create")
        fileId = args.get("fileId", "")
        name = args.get("name", "")

        if not content:
            return ToolResult(toolCallId="", toolName="writeFile", success=False, error="content is required")

        try:
            chatService = services.chat
            dbMgmt = chatService.interfaceDbComponent

            if mode == "append":
                if not fileId:
                    return ToolResult(toolCallId="", toolName="writeFile", success=False, error="fileId is required for mode=append")
                file = dbMgmt.getFile(fileId)
                if not file:
                    return ToolResult(toolCallId="", toolName="writeFile", success=False, error=f"File {fileId} not found")
                existingData = dbMgmt.getFileData(fileId) or b""
                try:
                    existingText = existingData.decode("utf-8")
                except UnicodeDecodeError:
                    existingText = existingData.decode("latin-1", errors="replace")
                newContent = existingText + content
                dbMgmt.updateFileData(fileId, newContent.encode("utf-8"))
                dbMgmt.updateFile(fileId, {"fileSize": len(newContent.encode("utf-8"))})
                return ToolResult(
                    toolCallId="", toolName="writeFile", success=True,
                    data=f"Appended {len(content)} chars to '{file.fileName}' (id: {fileId}, total: {len(newContent)} chars)",
                    sideEvents=[{"type": "fileUpdated", "data": {"fileId": fileId, "fileName": file.fileName}}],
                )

            if mode == "overwrite":
                if not fileId:
                    return ToolResult(toolCallId="", toolName="writeFile", success=False, error="fileId is required for mode=overwrite")
                file = dbMgmt.getFile(fileId)
                if not file:
                    return ToolResult(toolCallId="", toolName="writeFile", success=False, error=f"File {fileId} not found")
                dbMgmt.updateFileData(fileId, content.encode("utf-8"))
                dbMgmt.updateFile(fileId, {"fileSize": len(content.encode("utf-8"))})
                return ToolResult(
                    toolCallId="", toolName="writeFile", success=True,
                    data=f"Overwritten '{file.fileName}' (id: {fileId}, {len(content)} chars)",
                    sideEvents=[{"type": "fileUpdated", "data": {"fileId": fileId, "fileName": file.fileName}}],
                )

            # mode == "create" (default)
            if not name:
                return ToolResult(toolCallId="", toolName="writeFile", success=False, error="name is required for mode=create")
            fileItem, _ = dbMgmt.saveUploadedFile(content.encode("utf-8"), name)
            fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "")
            if fiId:
                dbMgmt.updateFile(fileItem.id, {"featureInstanceId": fiId})
            if args.get("folderId"):
                dbMgmt.updateFile(fileItem.id, {"folderId": args["folderId"]})
            if args.get("tags"):
                dbMgmt.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. Returns full content by default. "
            "For large files, use offset and limit to read specific line ranges. "
            "When truncated, the response tells the total line count so you can paginate."
        ),
        parameters={
            "type": "object",
            "properties": {
                "fileId": {"type": "string", "description": "The file ID to read"},
                "offset": {"type": "integer", "description": "Start reading from this line number (1-based). Omit for full file."},
                "limit": {"type": "integer", "description": "Max number of lines to return (default: all). Use with offset for chunked reading."},
            },
            "required": ["fileId"]
        },
        readOnly=True
    )

    registry.register(
        "listFiles", _listFiles,
        description=(
            "List files in the local workspace. Filter by folder, tags, or search term. "
            "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(
        "searchInFileContent", _searchInFileContent,
        description=(
            "Search for text within a file's content. Returns matching lines with context. "
            "Case-insensitive. Use to locate specific text before using replaceInFile, "
            "or to find relevant sections in a large file before reading with offset/limit."
        ),
        parameters={
            "type": "object",
            "properties": {
                "fileId": {"type": "string", "description": "The file ID to search in"},
                "query": {"type": "string", "description": "Text to search for (case-insensitive)"},
                "contextLines": {"type": "integer", "description": "Number of context lines around each match (default: 2)"},
            },
            "required": ["fileId", "query"]
        },
        readOnly=True
    )

    registry.register(
        "listFolders", _listFolders,
        description="List folders in the local workspace. 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 general information. Use readUrl to fetch content from a known URL instead.",
        parameters={
            "type": "object",
            "properties": {"query": {"type": "string", "description": "Search query"}},
            "required": ["query"]
        },
        readOnly=True
    )

    registry.register(
        "tagFile", _tagFile,
        description="Set or update tags on a file for categorization and filtering via listFiles.",
        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 in the local workspace.",
        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 folder in the local workspace.",
        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, append, or overwrite a file. Modes:\n"
            "- create (default): create a new file (name required).\n"
            "- append: append content to an existing file (fileId required). "
            "Use for large content that exceeds a single tool call (~8000 chars per call).\n"
            "- overwrite: replace entire file content (fileId required)."
        ),
        parameters={
            "type": "object",
            "properties": {
                "name": {"type": "string", "description": "File name (required for mode=create)"},
                "content": {"type": "string", "description": "Content to write/append"},
                "mode": {"type": "string", "enum": ["create", "append", "overwrite"], "description": "Write mode (default: create)"},
                "fileId": {"type": "string", "description": "File ID (required for mode=append/overwrite)"},
                "folderId": {"type": "string", "description": "Target folder ID (mode=create only)"},
                "tags": {"type": "array", "items": {"type": "string"}, "description": "Tags (mode=create only)"},
            },
            "required": ["content"]
        },
        readOnly=False
    )

    # ---- Phase 1: deleteFile, renameFile, readUrl, translateText ----

    async def _deleteFile(args: Dict[str, Any], context: Dict[str, Any]):
        fileId = args.get("fileId", "")
        if not fileId:
            return ToolResult(toolCallId="", toolName="deleteFile", success=False, error="fileId is required")
        try:
            chatService = services.chat
            file = chatService.interfaceDbComponent.getFile(fileId)
            if not file:
                return ToolResult(toolCallId="", toolName="deleteFile", success=False, error=f"File {fileId} not found")
            fileName = file.fileName
            try:
                knowledgeService = services.getService("knowledge")
                if knowledgeService and hasattr(knowledgeService, "removeFile"):
                    knowledgeService.removeFile(fileId)
            except Exception:
                pass
            chatService.interfaceDbComponent.deleteFile(fileId)
            return ToolResult(
                toolCallId="", toolName="deleteFile", success=True,
                data=f"File '{fileName}' (id: {fileId}) deleted",
                sideEvents=[{"type": "fileDeleted", "data": {"fileId": fileId, "fileName": fileName}}],
            )
        except Exception as e:
            return ToolResult(toolCallId="", toolName="deleteFile", success=False, error=str(e))

    async def _renameFile(args: Dict[str, Any], context: Dict[str, Any]):
        fileId = args.get("fileId", "")
        newName = args.get("newName", "")
        if not fileId or not newName:
            return ToolResult(toolCallId="", toolName="renameFile", success=False, error="fileId and newName are required")
        try:
            chatService = services.chat
            chatService.interfaceDbComponent.updateFile(fileId, {"fileName": newName})
            return ToolResult(
                toolCallId="", toolName="renameFile", success=True,
                data=f"File {fileId} renamed to '{newName}'",
                sideEvents=[{"type": "fileUpdated", "data": {"fileId": fileId, "fileName": newName}}],
            )
        except Exception as e:
            return ToolResult(toolCallId="", toolName="renameFile", success=False, error=str(e))

    async def _readUrl(args: Dict[str, Any], context: Dict[str, Any]):
        url = args.get("url", "")
        if not url:
            return ToolResult(toolCallId="", toolName="readUrl", success=False, error="url is required")
        try:
            webService = services.getService("web")
            result = await webService._performWebCrawl(
                instruction="Extract all content from this page",
                urls=[url],
                maxDepth=1,
                maxWidth=1,
            )
            if isinstance(result, list) and result:
                content = "\n\n".join(
                    item.get("content", "") or item.get("text", "") or str(item)
                    for item in result if item
                )
            elif isinstance(result, dict):
                content = result.get("content", "") or result.get("summary", "") or str(result)
            else:
                content = str(result) if result else "No content retrieved"
            _MAX = 30000
            if len(content) > _MAX:
                content = content[:_MAX] + f"\n\n... (truncated at {_MAX} chars)"
            return ToolResult(toolCallId="", toolName="readUrl", success=True, data=content)
        except Exception as e:
            return ToolResult(toolCallId="", toolName="readUrl", success=False, error=str(e))

    async def _translateText(args: Dict[str, Any], context: Dict[str, Any]):
        text = args.get("text", "")
        targetLanguage = args.get("targetLanguage", "")
        if not text or not targetLanguage:
            return ToolResult(toolCallId="", toolName="translateText", success=False, error="text and targetLanguage are required")
        try:
            from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
            mandateId = context.get("mandateId", "")
            voiceInterface = getVoiceInterface(currentUser=None, mandateId=mandateId)
            sourceLanguage = args.get("sourceLanguage", "auto")
            result = await voiceInterface.translateText(text, sourceLanguage=sourceLanguage, targetLanguage=targetLanguage)
            if result and result.get("success"):
                translated = result.get("translated_text", "")
                return ToolResult(toolCallId="", toolName="translateText", success=True, data=translated)
            return ToolResult(toolCallId="", toolName="translateText", success=False, error=result.get("error", "Translation failed"))
        except Exception as e:
            return ToolResult(toolCallId="", toolName="translateText", success=False, error=str(e))

    registry.register(
        "deleteFile", _deleteFile,
        description="Permanently delete a file from the local workspace.",
        parameters={
            "type": "object",
            "properties": {
                "fileId": {"type": "string", "description": "The file ID to delete"},
            },
            "required": ["fileId"]
        },
        readOnly=False
    )

    registry.register(
        "renameFile", _renameFile,
        description="Rename a file in the local workspace. Include the file extension in the new name.",
        parameters={
            "type": "object",
            "properties": {
                "fileId": {"type": "string", "description": "The file ID to rename"},
                "newName": {"type": "string", "description": "New file name including extension"},
            },
            "required": ["fileId", "newName"]
        },
        readOnly=False
    )

    registry.register(
        "readUrl", _readUrl,
        description=(
            "Read and extract content from a specific URL. "
            "Use when the user provides a specific URL to read, or when you need to fetch content from a known web page. "
            "For general information searches, use webSearch instead."
        ),
        parameters={
            "type": "object",
            "properties": {
                "url": {"type": "string", "description": "The URL to read"},
            },
            "required": ["url"]
        },
        readOnly=True
    )

    registry.register(
        "translateText", _translateText,
        description=(
            "Translate text to a target language using Google Cloud Translation. "
            "More efficient than AI translation for large text volumes. "
            "Use ISO language codes (e.g. 'en', 'de', 'fr', 'es', 'it', 'pt', 'zh', 'ja', 'ko', 'ar')."
        ),
        parameters={
            "type": "object",
            "properties": {
                "text": {"type": "string", "description": "Text to translate"},
                "targetLanguage": {"type": "string", "description": "Target language ISO code (e.g. 'en', 'de', 'fr')"},
                "sourceLanguage": {"type": "string", "description": "Source language ISO code (default: auto-detect)"},
            },
            "required": ["text", "targetLanguage"]
        },
        readOnly=True
    )

    # ---- Phase 2: deleteFolder, renameFolder, moveFolder, copyFile, editFile ----

    async def _deleteFolder(args: Dict[str, Any], context: Dict[str, Any]):
        folderId = args.get("folderId", "")
        recursive = args.get("recursive", False)
        if not folderId:
            return ToolResult(toolCallId="", toolName="deleteFolder", success=False, error="folderId is required")
        try:
            chatService = services.chat
            result = chatService.interfaceDbComponent.deleteFolder(folderId, recursive=recursive)
            summary = f"Deleted {result.get('deletedFolders', 1)} folder(s) and {result.get('deletedFiles', 0)} file(s)"
            return ToolResult(
                toolCallId="", toolName="deleteFolder", success=True, data=summary,
                sideEvents=[{"type": "folderDeleted", "data": {"folderId": folderId, **result}}],
            )
        except Exception as e:
            return ToolResult(toolCallId="", toolName="deleteFolder", success=False, error=str(e))

    async def _renameFolder(args: Dict[str, Any], context: Dict[str, Any]):
        folderId = args.get("folderId", "")
        newName = args.get("newName", "")
        if not folderId or not newName:
            return ToolResult(toolCallId="", toolName="renameFolder", success=False, error="folderId and newName are required")
        try:
            chatService = services.chat
            chatService.interfaceDbComponent.renameFolder(folderId, newName)
            return ToolResult(
                toolCallId="", toolName="renameFolder", success=True,
                data=f"Folder {folderId} renamed to '{newName}'",
                sideEvents=[{"type": "folderUpdated", "data": {"folderId": folderId, "name": newName}}],
            )
        except Exception as e:
            return ToolResult(toolCallId="", toolName="renameFolder", success=False, error=str(e))

    async def _moveFolder(args: Dict[str, Any], context: Dict[str, Any]):
        folderId = args.get("folderId", "")
        targetParentId = args.get("targetParentId")
        if not folderId:
            return ToolResult(toolCallId="", toolName="moveFolder", success=False, error="folderId is required")
        try:
            chatService = services.chat
            chatService.interfaceDbComponent.moveFolder(folderId, targetParentId)
            return ToolResult(
                toolCallId="", toolName="moveFolder", success=True,
                data=f"Folder {folderId} moved to {targetParentId or 'root'}",
                sideEvents=[{"type": "folderUpdated", "data": {"folderId": folderId, "parentId": targetParentId}}],
            )
        except Exception as e:
            return ToolResult(toolCallId="", toolName="moveFolder", success=False, error=str(e))

    async def _copyFile(args: Dict[str, Any], context: Dict[str, Any]):
        fileId = args.get("fileId", "")
        if not fileId:
            return ToolResult(toolCallId="", toolName="copyFile", success=False, error="fileId is required")
        try:
            chatService = services.chat
            copiedFile = chatService.interfaceDbComponent.copyFile(
                fileId,
                targetFolderId=args.get("targetFolderId"),
                newFileName=args.get("newFileName"),
            )
            return ToolResult(
                toolCallId="", toolName="copyFile", success=True,
                data=f"File copied as '{copiedFile.fileName}' (id: {copiedFile.id})",
                sideEvents=[{
                    "type": "fileCreated",
                    "data": {"fileId": copiedFile.id, "fileName": copiedFile.fileName,
                             "mimeType": copiedFile.mimeType, "fileSize": copiedFile.fileSize},
                }],
            )
        except Exception as e:
            return ToolResult(toolCallId="", toolName="copyFile", success=False, error=str(e))

    async def _replaceInFile(args: Dict[str, Any], context: Dict[str, Any]):
        fileId = args.get("fileId", "")
        oldText = args.get("oldText", "")
        newText = args.get("newText", "")
        replaceAll = args.get("replaceAll", False)
        if not fileId or not oldText:
            return ToolResult(toolCallId="", toolName="replaceInFile", success=False, error="fileId and oldText are required")
        try:
            chatService = services.chat
            dbMgmt = chatService.interfaceDbComponent
            file = dbMgmt.getFile(fileId)
            if not file:
                return ToolResult(toolCallId="", toolName="replaceInFile", success=False, error=f"File {fileId} not found")
            if not dbMgmt.isTextMimeType(file.mimeType):
                return ToolResult(
                    toolCallId="", toolName="replaceInFile", success=False,
                    error=f"Cannot edit binary file ({file.mimeType}). Only text-based files are supported."
                )
            rawData = dbMgmt.getFileData(fileId)
            if not rawData:
                return ToolResult(toolCallId="", toolName="replaceInFile", success=False, error="File has no content")
            try:
                oldContent = rawData.decode("utf-8")
            except UnicodeDecodeError:
                return ToolResult(toolCallId="", toolName="replaceInFile", success=False, error="File content is not valid UTF-8 text")

            count = oldContent.count(oldText)
            if count == 0:
                return ToolResult(
                    toolCallId="", toolName="replaceInFile", success=False,
                    error="oldText not found in file. Use readFile or searchInFileContent to verify the exact text."
                )
            if count > 1 and not replaceAll:
                return ToolResult(
                    toolCallId="", toolName="replaceInFile", success=False,
                    error=f"oldText found {count} times. Set replaceAll=true or provide more surrounding context to make it unique."
                )

            newContent = oldContent.replace(oldText, newText) if replaceAll else oldContent.replace(oldText, newText, 1)

            editId = str(_uuid.uuid4())
            label = f"all {count} occurrences" if replaceAll else "1 occurrence"
            return ToolResult(
                toolCallId="", toolName="replaceInFile", success=True,
                data=f"Edit proposed for '{file.fileName}': replaced {label}. Waiting for user review.",
                sideEvents=[{
                    "type": "fileEditProposal",
                    "data": {
                        "id": editId,
                        "fileId": fileId,
                        "fileName": file.fileName,
                        "mimeType": file.mimeType,
                        "oldContent": oldContent,
                        "newContent": newContent,
                    },
                }],
            )
        except Exception as e:
            return ToolResult(toolCallId="", toolName="replaceInFile", success=False, error=str(e))

    registry.register(
        "deleteFolder", _deleteFolder,
        description="Delete a folder from the local workspace. Set recursive=true to delete all contents.",
        parameters={
            "type": "object",
            "properties": {
                "folderId": {"type": "string", "description": "The folder ID to delete"},
                "recursive": {"type": "boolean", "description": "If true, delete folder and all contents (files and subfolders). Default: false"},
            },
            "required": ["folderId"]
        },
        readOnly=False
    )

    registry.register(
        "renameFolder", _renameFolder,
        description="Rename a folder in the local workspace.",
        parameters={
            "type": "object",
            "properties": {
                "folderId": {"type": "string", "description": "The folder ID to rename"},
                "newName": {"type": "string", "description": "New folder name"},
            },
            "required": ["folderId", "newName"]
        },
        readOnly=False
    )

    registry.register(
        "moveFolder", _moveFolder,
        description="Move a folder to a different parent in the local workspace.",
        parameters={
            "type": "object",
            "properties": {
                "folderId": {"type": "string", "description": "The folder ID to move"},
                "targetParentId": {"type": "string", "description": "Target parent folder ID (null/omit for root)"},
            },
            "required": ["folderId"]
        },
        readOnly=False
    )

    registry.register(
        "copyFile", _copyFile,
        description="Create an independent copy of a file in the local workspace.",
        parameters={
            "type": "object",
            "properties": {
                "fileId": {"type": "string", "description": "The file ID to copy"},
                "targetFolderId": {"type": "string", "description": "Target folder for the copy (default: same folder)"},
                "newFileName": {"type": "string", "description": "New file name (default: same name, auto-numbered if duplicate)"},
            },
            "required": ["fileId"]
        },
        readOnly=False
    )

    registry.register(
        "replaceInFile", _replaceInFile,
        description=(
            "Replace specific text in an existing file. The edit is shown to the user for "
            "review (accept/reject) before being applied. Provide enough surrounding context "
            "in oldText to make the match unique (at least 2-3 lines). "
            "Use readFile or searchInFileContent first to identify the exact text to replace."
        ),
        parameters={
            "type": "object",
            "properties": {
                "fileId": {"type": "string", "description": "The file ID to edit"},
                "oldText": {"type": "string", "description": "Exact text to find and replace (must be unique unless replaceAll=true)"},
                "newText": {"type": "string", "description": "The replacement text"},
                "replaceAll": {"type": "boolean", "description": "Replace all occurrences (default: false)"},
            },
            "required": ["fileId", "oldText", "newText"]
        },
        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 _uploadToExternal(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="uploadToExternal", 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="uploadToExternal", 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="uploadToExternal", success=True, data=str(result))
        except Exception as e:
            return ToolResult(toolCallId="", toolName="uploadToExternal", success=False, error=str(e))

    async def _sendMail(args: Dict[str, Any], context: Dict[str, Any]):
        import base64 as _b64

        connectionId = args.get("connectionId", "")
        to = args.get("to", [])
        subject = args.get("subject", "")
        body = args.get("body", "")
        bodyType = "HTML" if args.get("bodyType", "text").lower() == "html" else "Text"
        draft = args.get("draft", False)
        attachmentFileIds = args.get("attachmentFileIds") or []

        if not connectionId or not to or not subject:
            return ToolResult(toolCallId="", toolName="sendMail", success=False, error="connectionId, to, and subject are required")
        try:
            graphAttachments: List[Dict[str, Any]] = []
            if attachmentFileIds:
                chatService = services.chat
                dbMgmt = chatService.interfaceDbComponent
                for fid in attachmentFileIds:
                    fileRow = dbMgmt.getFile(fid)
                    if not fileRow:
                        return ToolResult(toolCallId="", toolName="sendMail", success=False, error=f"Attachment file not found: {fid}")
                    rawBytes = dbMgmt.getFileData(fid)
                    if not rawBytes:
                        return ToolResult(toolCallId="", toolName="sendMail", success=False, error=f"Attachment file has no data: {fid}")
                    graphAttachments.append({
                        "name": fileRow.fileName,
                        "contentBytes": _b64.b64encode(rawBytes).decode("ascii"),
                        "contentType": getattr(fileRow, "mimeType", "application/octet-stream"),
                    })

            from modules.connectors.connectorResolver import ConnectorResolver
            resolver = ConnectorResolver(
                services.getService("security"),
                _buildResolverDb(),
            )
            adapter = await resolver.resolveService(connectionId, "outlook")

            if draft and hasattr(adapter, "createDraft"):
                result = await adapter.createDraft(
                    to=to, subject=subject, body=body, bodyType=bodyType,
                    cc=args.get("cc"), attachments=graphAttachments or None,
                )
                return ToolResult(toolCallId="", toolName="sendMail", success=True, data=str(result))

            if hasattr(adapter, "sendMail"):
                result = await adapter.sendMail(
                    to=to, subject=subject, body=body, bodyType=bodyType,
                    cc=args.get("cc"), attachments=graphAttachments or None,
                )
                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 the user's external connections (SharePoint, OneDrive, Outlook, etc.) and their IDs. Use with browseDataSource/uploadToExternal.",
        parameters={"type": "object", "properties": {}},
        readOnly=True,
    )

    registry.register(
        "uploadToExternal", _uploadToExternal,
        description=(
            "Upload a local file to an external storage via connectionId+service. "
            "Use listConnections to find available connections."
        ),
        parameters={
            "type": "object",
            "properties": {
                **_connToolParams,
                "path": {"type": "string", "description": "Destination path on the external service"},
                "fileId": {"type": "string", "description": "Local file ID to upload"},
            },
            "required": ["connectionId", "service", "path", "fileId"],
        },
        readOnly=False,
    )

    registry.register(
        "sendMail", _sendMail,
        description=(
            "Send or draft an email via a connected mail service (Outlook). "
            "Supports HTML body and file attachments from the workspace. "
            "Set draft=true to save as draft without sending. "
            "Use listConnections to find the connectionId."
        ),
        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 — plain text or HTML markup"},
                "bodyType": {"type": "string", "enum": ["text", "html"], "description": "Body format: 'text' (default) or 'html'"},
                "cc": {"type": "array", "items": {"type": "string"}, "description": "CC addresses"},
                "attachmentFileIds": {
                    "type": "array", "items": {"type": "string"},
                    "description": "File IDs from the workspace to attach (use listFiles to find IDs)",
                },
                "draft": {"type": "boolean", "description": "If true, save as draft in Drafts folder instead of sending"},
            },
            "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

    _MAIL_SERVICES = {"outlook", "gmail"}

    async def _browseDataSource(args: Dict[str, Any], context: Dict[str, Any]):
        dsId = args.get("dataSourceId", "")
        subPath = args.get("subPath", "")
        directConnId = args.get("connectionId", "")
        directService = args.get("service", "")
        if not dsId and not (directConnId and directService):
            return ToolResult(toolCallId="", toolName="browseDataSource", success=False,
                              error="Provide either dataSourceId OR connectionId+service")
        try:
            if dsId:
                connectionId, service, basePath = await _resolveDataSource(dsId)
            else:
                connectionId, service, basePath = directConnId, directService, args.get("path", "/")
            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}")
            result = "\n".join(lines)
            if service in _MAIL_SERVICES:
                result += "\n\nIMPORTANT: These are email subjects only. To read the full email content, use downloadFromDataSource with the path, then readFile on the returned file ID."
            return ToolResult(toolCallId="", toolName="browseDataSource", success=True, data=result)
        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", "")
        directConnId = args.get("connectionId", "")
        directService = args.get("service", "")
        query = args.get("query", "")
        if not query:
            return ToolResult(toolCallId="", toolName="searchDataSource", success=False, error="query is required")
        if not dsId and not (directConnId and directService):
            return ToolResult(toolCallId="", toolName="searchDataSource", success=False,
                              error="Provide either dataSourceId OR connectionId+service")
        try:
            if dsId:
                connectionId, service, basePath = await _resolveDataSource(dsId)
            else:
                connectionId, service, basePath = directConnId, directService, args.get("path", "/")
            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]
            result = "\n".join(lines)
            if service in _MAIL_SERVICES:
                result += "\n\nIMPORTANT: These are email subjects only. To read the full email content, use downloadFromDataSource with the path, then readFile on the returned file ID."
            return ToolResult(toolCallId="", toolName="searchDataSource", success=True, data=result)
        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", "")
        directConnId = args.get("connectionId", "")
        directService = args.get("service", "")
        filePath = args.get("filePath", "")
        fileName = args.get("fileName", "")
        if not filePath:
            return ToolResult(toolCallId="", toolName="downloadFromDataSource", success=False, error="filePath is required")
        if not dsId and not (directConnId and directService):
            return ToolResult(toolCallId="", toolName="downloadFromDataSource", success=False,
                              error="Provide either dataSourceId OR connectionId+service")
        try:
            from modules.connectors.connectorResolver import ConnectorResolver
            from modules.connectors.connectorProviderBase import DownloadResult as _DR
            if dsId:
                connectionId, service, basePath = await _resolveDataSource(dsId)
            else:
                connectionId, service, basePath = directConnId, directService, "/"
            fullPath = filePath if filePath.startswith("/") else f"{basePath.rstrip('/')}/{filePath}"
            resolver = ConnectorResolver(
                services.getService("security"),
                _buildResolverDb(),
            )
            adapter = await resolver.resolveService(connectionId, service)
            result = await adapter.download(fullPath)

            if isinstance(result, _DR):
                fileBytes = result.data
                fileName = result.fileName or fileName
            else:
                fileBytes = result

            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:
                    if fileBytes[:4] == b"%PDF":
                        fileName = f"{fileName}.pdf"
                    elif fileBytes[:2] == b"PK":
                        fileName = f"{fileName}.zip"
            chatService = services.chat
            fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile(fileBytes, fileName)
            fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "")
            if fiId:
                chatService.interfaceDbComponent.updateFile(fileItem.id, {"featureInstanceId": fiId})
            tempFolderId = _getOrCreateTempFolder(chatService)
            if tempFolderId:
                chatService.interfaceDbComponent.updateFile(fileItem.id, {"folderId": tempFolderId})
            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", "eml", "msg") 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 a data source. Accepts either:\n"
            "- dataSourceId (for attached data sources shown in the prompt), OR\n"
            "- connectionId + service (for direct connection access via listConnections)."
        ),
        parameters={
            "type": "object",
            "properties": {
                "dataSourceId": {"type": "string", "description": "DataSource ID (from attached data sources)"},
                "connectionId": {"type": "string", "description": "UserConnection ID (alternative to dataSourceId)"},
                "service": {"type": "string", "description": "Service name (alternative to dataSourceId, e.g. sharepoint, onedrive)"},
                "path": {"type": "string", "description": "Root path (used with connectionId+service)"},
                "subPath": {"type": "string", "description": "Sub-path within the data source to browse"},
                "filter": {"type": "string", "description": "Filter pattern (e.g. '*.pdf')"},
            },
        },
        readOnly=True,
    )

    registry.register(
        "searchDataSource", _searchDataSource,
        description=(
            "Search for files within a data source. Accepts either dataSourceId OR connectionId+service."
        ),
        parameters={
            "type": "object",
            "properties": {
                "dataSourceId": {"type": "string", "description": "DataSource ID"},
                "connectionId": {"type": "string", "description": "UserConnection ID (alternative to dataSourceId)"},
                "service": {"type": "string", "description": "Service name (alternative to dataSourceId)"},
                "path": {"type": "string", "description": "Scope path (used with connectionId+service)"},
                "query": {"type": "string", "description": "Search query"},
            },
            "required": ["query"],
        },
        readOnly=True,
    )

    registry.register(
        "downloadFromDataSource", _downloadFromDataSource,
        description=(
            "Download a file or email from a data source into local storage. Returns a local file ID "
            "to read with readFile. Accepts either dataSourceId OR connectionId+service. "
            "For email sources (Outlook, Gmail), browse/search only return subjects -- use this to get full content."
        ),
        parameters={
            "type": "object",
            "properties": {
                "dataSourceId": {"type": "string", "description": "DataSource ID"},
                "connectionId": {"type": "string", "description": "UserConnection ID (alternative to dataSourceId)"},
                "service": {"type": "string", "description": "Service name (alternative to dataSourceId)"},
                "filePath": {"type": "string", "description": "Path of the file to download (from browseDataSource results)"},
                "fileName": {"type": "string", "description": "File name with extension (e.g. 'report.pdf')"},
            },
            "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=False, error=f"Item '{containerPath}' not found in container index for file {fileId}. On-demand extraction is not yet implemented.")
        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 document (pages, sections, sheets, slides). Use before readContentObjects for targeted reading.",
        parameters={
            "type": "object",
            "properties": {"fileId": {"type": "string", "description": "The file ID to browse"}},
            "required": ["fileId"],
        },
        readOnly=True,
    )

    registry.register(
        "readContentObjects", _readContentObjects,
        description="Read extracted content objects from a file, optionally filtered by page, section, or type. Use browseContainer first to see the structure.",
        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="Extract a specific item from a container file (ZIP, nested file). Use browseContainer to see available items.",
        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="Generate an AI-powered summary of a file's content. Optionally filter by section, page, or content type.",
        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="Analyze an image using AI vision. Works with image files and images extracted from PDFs/DOCX/PPTX. Use for OCR, data extraction, and visual analysis.",
        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
        sourceFileId = (args.get("sourceFileId") or "").strip()
        content = args.get("content", "")
        if not isinstance(content, str):
            content = str(content) if content is not None else ""
        outputFormat = args.get("outputFormat", "pdf")
        title = args.get("title", "Document")
        language = args.get("language", "de")

        if sourceFileId:
            try:
                dbMgmt = services.chat.interfaceDbComponent
                fileRow = dbMgmt.getFile(sourceFileId)
                if not fileRow:
                    return ToolResult(
                        toolCallId="",
                        toolName="renderDocument",
                        success=False,
                        error=f"sourceFileId not found: {sourceFileId}",
                    )
                rawBytes = dbMgmt.getFileData(sourceFileId)
                if not rawBytes:
                    return ToolResult(
                        toolCallId="",
                        toolName="renderDocument",
                        success=False,
                        error=f"sourceFileId has no data: {sourceFileId}",
                    )
                try:
                    content = rawBytes.decode("utf-8")
                except UnicodeDecodeError:
                    content = rawBytes.decode("latin-1", errors="replace")
            except Exception as e:
                return ToolResult(
                    toolCallId="",
                    toolName="renderDocument",
                    success=False,
                    error=f"Could not read sourceFileId: {e}",
                )

        if not (content or "").strip():
            return ToolResult(
                toolCallId="",
                toolName="renderDocument",
                success=False,
                error=(
                    "Provide non-empty `content` (markdown) or `sourceFileId` (id of a .md/.txt from writeFile). "
                    "For long documents use writeFile create+append, then renderDocument(sourceFileId=...)."
                ),
            )

        modelMaxTokens = context.get("modelMaxOutputTokens", 0)
        _inlineCharLimit = int(modelMaxTokens * 3 * 0.5) if modelMaxTokens > 0 else 6000
        _inlineCharLimit = max(_inlineCharLimit, 3000)

        if not sourceFileId and len(content) > _inlineCharLimit:
            return ToolResult(
                toolCallId="",
                toolName="renderDocument",
                success=False,
                error=(
                    f"Inline `content` is {len(content)} chars — over the {_inlineCharLimit} char limit "
                    f"(derived from model output budget of {modelMaxTokens} tokens). "
                    "Large documents must use the file path:\n"
                    "1. writeFile(mode='create', name='draft.md', content=<first ~5000 chars>)\n"
                    "2. writeFile(mode='append', fileId=<id>, content=<next chunk>) — repeat as needed\n"
                    "3. renderDocument(sourceFileId=<id>, outputFormat='pdf', title='...')\n"
                    "This avoids output truncation entirely."
                ),
            )

        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})
                    tempFolderId = _getOrCreateTempFolder(chatService)
                    if tempFolderId:
                        chatService.interfaceDbComponent.updateFile(fid, {"folderId": tempFolderId})
                    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 into a document file (PDF, DOCX, XLSX, PPTX, CSV, HTML, MD, JSON, TXT). "
            "For long documents: write markdown with writeFile (mode=create then append chunks), then call this tool with "
            "`sourceFileId` only (tiny JSON — avoids model output truncation). For short docs you may pass `content` inline. "
            "Images: ![alt text](file:fileId) in the markdown."
        ),
        parameters={
            "type": "object",
            "properties": {
                "content": {
                    "type": "string",
                    "description": "Full markdown inline. Prefer `sourceFileId` when the document is large (many KB).",
                },
                "sourceFileId": {
                    "type": "string",
                    "description": "Chat file id of markdown saved via writeFile (create+append). Use this instead of `content` for long PDFs.",
                },
                "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"},
            },
        },
        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:
                    from modules.datamodels.datamodelUam import UserVoicePreferences
                    from modules.security.rootAccess import getRootInterface
                    userId = context.get("userId", "")
                    if userId:
                        rootIf = getRootInterface()
                        prefRecords = rootIf.db.getRecordset(
                            UserVoicePreferences,
                            recordFilter={"userId": userId, "mandateId": mandateId}
                        )
                        if not prefRecords and mandateId:
                            prefRecords = rootIf.db.getRecordset(
                                UserVoicePreferences,
                                recordFilter={"userId": userId}
                            )
                        if prefRecords:
                            vs = prefRecords[0] if isinstance(prefRecords[0], dict) else prefRecords[0].model_dump() if hasattr(prefRecords[0], "model_dump") else prefRecords[0]
                            voiceMap = vs.get("ttsVoiceMap", {}) or {}
                            if isinstance(voiceMap, dict) and voiceMap:
                                selectedKey = None
                                selectedVoiceEntry = None
                                baseLanguage = language.split("-")[0].lower() if isinstance(language, str) and language else ""

                                if isinstance(language, str) and language in voiceMap:
                                    selectedKey = language
                                    selectedVoiceEntry = voiceMap[language]

                                if selectedVoiceEntry is None and baseLanguage and baseLanguage in voiceMap:
                                    selectedKey = baseLanguage
                                    selectedVoiceEntry = voiceMap[baseLanguage]

                                if selectedVoiceEntry is None and baseLanguage:
                                    for mapKey, mapValue in voiceMap.items():
                                        mapKeyNorm = str(mapKey).lower()
                                        if mapKeyNorm == baseLanguage or mapKeyNorm.startswith(f"{baseLanguage}-"):
                                            selectedKey = str(mapKey)
                                            selectedVoiceEntry = mapValue
                                            break

                                if selectedVoiceEntry is not None:
                                    voiceName = (
                                        selectedVoiceEntry.get("voiceName")
                                        if isinstance(selectedVoiceEntry, dict)
                                        else selectedVoiceEntry
                                    )
                                    logger.info(
                                        f"textToSpeech: using configured voice '{voiceName}' for requested language '{language}' (matched key '{selectedKey}')"
                                    )
                            if not voiceName and vs.get("ttsVoice") and vs.get("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})
                    tempFolderId = _getOrCreateTempFolder(chatService)
                    if tempFolderId:
                        chatService.interfaceDbComponent.updateFile(fid, {"folderId": tempFolderId})
                    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,
    )

    # ── createChart tool ─────────────────────────────────────────────────

    async def _createChart(args: Dict[str, Any], context: Dict[str, Any]):
        """Create a data chart as PNG image using matplotlib."""
        import re as _re

        chartType = (args.get("chartType") or "bar").strip().lower()
        title = (args.get("title") or "Chart").strip()
        labels = args.get("labels") or []
        datasets = args.get("datasets") or []
        xLabel = (args.get("xLabel") or "").strip()
        yLabel = (args.get("yLabel") or "").strip()
        width = min(max(args.get("width") or 10, 4), 20)
        height = min(max(args.get("height") or 6, 3), 14)
        colors = args.get("colors") or None

        if not datasets:
            return ToolResult(toolCallId="", toolName="createChart", success=False, error="datasets is required (list of {label, values})")

        try:
            import matplotlib
            matplotlib.use("Agg")
            import logging as _mpllog
            _mpllog.getLogger("matplotlib").setLevel(_mpllog.WARNING)
            import matplotlib.pyplot as plt
            import io

            _DEFAULT_COLORS = [
                "#4285F4", "#EA4335", "#FBBC04", "#34A853", "#FF6D01",
                "#46BDC6", "#7B61FF", "#F538A0", "#00ACC1", "#AB47BC",
            ]
            usedColors = colors if colors and len(colors) >= len(datasets) else _DEFAULT_COLORS

            fig, ax = plt.subplots(figsize=(width, height))
            fig.patch.set_facecolor("#FFFFFF")
            ax.set_facecolor("#FAFAFA")

            if chartType in ("pie", "donut"):
                values = datasets[0].get("values", []) if datasets else []
                explode = [0.02] * len(values)
                wedges, texts, autotexts = ax.pie(
                    values, labels=labels, autopct="%1.1f%%",
                    colors=usedColors[:len(values)], explode=explode,
                    textprops={"fontsize": 9},
                )
                if chartType == "donut":
                    ax.add_artist(plt.Circle((0, 0), 0.55, fc="white"))
                ax.set_title(title, fontsize=14, fontweight="bold", pad=16)

            else:
                import numpy as _np
                x = _np.arange(len(labels)) if labels else _np.arange(max(len(d.get("values", [])) for d in datasets))
                barWidth = 0.8 / max(len(datasets), 1)

                for i, ds in enumerate(datasets):
                    dsLabel = ds.get("label", f"Series {i+1}")
                    values = ds.get("values", [])
                    color = usedColors[i % len(usedColors)]

                    if chartType == "bar":
                        offset = (i - len(datasets) / 2 + 0.5) * barWidth
                        ax.bar(x + offset, values, barWidth, label=dsLabel, color=color, edgecolor="white", linewidth=0.5)
                    elif chartType == "horizontalbar":
                        offset = (i - len(datasets) / 2 + 0.5) * barWidth
                        ax.barh(x + offset, values, barWidth, label=dsLabel, color=color, edgecolor="white", linewidth=0.5)
                    elif chartType == "line":
                        ax.plot(x[:len(values)], values, marker="o", markersize=5, label=dsLabel, color=color, linewidth=2)
                    elif chartType == "area":
                        ax.fill_between(x[:len(values)], values, alpha=0.3, color=color)
                        ax.plot(x[:len(values)], values, label=dsLabel, color=color, linewidth=2)
                    elif chartType == "scatter":
                        ax.scatter(x[:len(values)], values, label=dsLabel, color=color, s=50, edgecolors="white", linewidth=0.5)
                    else:
                        ax.bar(x, values, label=dsLabel, color=color)

                if labels:
                    if chartType == "horizontalbar":
                        ax.set_yticks(x)
                        ax.set_yticklabels(labels, fontsize=9)
                    else:
                        ax.set_xticks(x)
                        ax.set_xticklabels(labels, fontsize=9, rotation=45 if len(labels) > 6 else 0, ha="right" if len(labels) > 6 else "center")

                ax.set_title(title, fontsize=14, fontweight="bold", pad=12)
                if xLabel:
                    ax.set_xlabel(xLabel, fontsize=10)
                if yLabel:
                    ax.set_ylabel(yLabel, fontsize=10)
                if len(datasets) > 1:
                    ax.legend(fontsize=9, framealpha=0.9)
                ax.grid(axis="y", alpha=0.3, linestyle="--")
                ax.spines["top"].set_visible(False)
                ax.spines["right"].set_visible(False)

            plt.tight_layout()
            buf = io.BytesIO()
            fig.savefig(buf, format="png", dpi=150, bbox_inches="tight")
            plt.close(fig)
            pngData = buf.getvalue()

            chatService = services.chat
            sanitizedTitle = _re.sub(r'[^\w._-]', '_', title, flags=_re.UNICODE).strip('_') or "chart"
            fileName = f"{sanitizedTitle}.png"

            if hasattr(chatService.interfaceDbComponent, "saveGeneratedFile"):
                fileItem = chatService.interfaceDbComponent.saveGeneratedFile(pngData, fileName, "image/png")
            else:
                fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile(pngData, fileName)

            fid = fileItem.id if hasattr(fileItem, "id") else fileItem.get("id", "?") if isinstance(fileItem, dict) else "?"
            fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "")
            if fiId and fid != "?":
                chatService.interfaceDbComponent.updateFile(fid, {"featureInstanceId": fiId})
            tempFolderId = _getOrCreateTempFolder(chatService)
            if tempFolderId and fid != "?":
                chatService.interfaceDbComponent.updateFile(fid, {"folderId": tempFolderId})

            sideEvents = [{"type": "fileCreated", "data": {
                "fileId": fid, "fileName": fileName,
                "mimeType": "image/png", "fileSize": len(pngData),
            }}]
            return ToolResult(
                toolCallId="", toolName="createChart", success=True,
                data=f"Chart saved as '{fileName}' (id: {fid}, {len(pngData)} bytes). "
                     f"Embed in documents with: ![{title}](file:{fid})",
                sideEvents=sideEvents,
            )

        except Exception as e:
            logger.error(f"createChart failed: {e}", exc_info=True)
            return ToolResult(toolCallId="", toolName="createChart", success=False, error=str(e))

    registry.register(
        "createChart", _createChart,
        description=(
            "Create a data chart/graph as a PNG image using matplotlib. "
            "Supported types: bar, horizontalBar, line, area, scatter, pie, donut. "
            "The chart is saved as a file in the workspace. "
            "Use the returned fileId to embed in documents via renderDocument: ![title](file:fileId). "
            "Provide structured data with labels and datasets."
        ),
        parameters={
            "type": "object",
            "properties": {
                "chartType": {
                    "type": "string",
                    "enum": ["bar", "horizontalBar", "line", "area", "scatter", "pie", "donut"],
                    "description": "Chart type (default: bar)",
                },
                "title": {"type": "string", "description": "Chart title"},
                "labels": {
                    "type": "array", "items": {"type": "string"},
                    "description": "X-axis labels / category names",
                },
                "datasets": {
                    "type": "array",
                    "items": {
                        "type": "object",
                        "properties": {
                            "label": {"type": "string", "description": "Series name (legend)"},
                            "values": {"type": "array", "items": {"type": "number"}, "description": "Data values"},
                        },
                        "required": ["values"],
                    },
                    "description": "Data series to plot",
                },
                "xLabel": {"type": "string", "description": "X-axis label"},
                "yLabel": {"type": "string", "description": "Y-axis label"},
                "colors": {
                    "type": "array", "items": {"type": "string"},
                    "description": "Custom hex colors for series (e.g. ['#4285F4', '#EA4335'])",
                },
                "width": {"type": "number", "description": "Figure width in inches (4-20, default 10)"},
                "height": {"type": "number", "description": "Figure height in inches (3-14, default 6)"},
            },
            "required": ["datasets"],
        },
        readOnly=False,
    )

    # ── Phase 3: speechToText, detectLanguage, neutralizeData, executeCode ──

    async def _speechToText(args: Dict[str, Any], context: Dict[str, Any]):
        fileId = args.get("fileId", "")
        if not fileId:
            return ToolResult(toolCallId="", toolName="speechToText", success=False, error="fileId is required")
        try:
            chatService = services.chat
            audioData = chatService.interfaceDbComponent.getFileData(fileId)
            if not audioData:
                return ToolResult(toolCallId="", toolName="speechToText", success=False, error=f"No data found for file {fileId}")
            from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
            mandateId = context.get("mandateId", "")
            voiceInterface = getVoiceInterface(currentUser=None, mandateId=mandateId)
            language = args.get("language", "de-DE")
            result = await voiceInterface.speechToText(audioData, language=language)
            if result and result.get("success"):
                transcript = result.get("text", "")
                confidence = result.get("confidence", 0)
                return ToolResult(
                    toolCallId="", toolName="speechToText", success=True,
                    data=f"Transcript (confidence: {confidence:.0%}):\n{transcript}"
                )
            return ToolResult(toolCallId="", toolName="speechToText", success=False, error=result.get("error", "Transcription failed"))
        except Exception as e:
            return ToolResult(toolCallId="", toolName="speechToText", success=False, error=str(e))

    async def _detectLanguage(args: Dict[str, Any], context: Dict[str, Any]):
        text = args.get("text", "")
        if not text:
            return ToolResult(toolCallId="", toolName="detectLanguage", success=False, error="text is required")
        try:
            from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
            mandateId = context.get("mandateId", "")
            voiceInterface = getVoiceInterface(currentUser=None, mandateId=mandateId)
            result = await voiceInterface.detectLanguage(text)
            if result and result.get("success"):
                lang = result.get("language", "unknown")
                return ToolResult(toolCallId="", toolName="detectLanguage", success=True, data=f"Detected language: {lang}")
            return ToolResult(toolCallId="", toolName="detectLanguage", success=False, error=result.get("error", "Detection failed"))
        except Exception as e:
            return ToolResult(toolCallId="", toolName="detectLanguage", success=False, error=str(e))

    async def _neutralizeData(args: Dict[str, Any], context: Dict[str, Any]):
        text = args.get("text", "")
        fileId = args.get("fileId", "")
        if not text and not fileId:
            return ToolResult(toolCallId="", toolName="neutralizeData", success=False, error="text or fileId is required")
        try:
            neutralizationService = services.getService("neutralization")
            if not neutralizationService:
                return ToolResult(toolCallId="", toolName="neutralizeData", success=False, error="Neutralization service not available")
            if not neutralizationService.interfaceDbComponent:
                neutralizationService.interfaceDbComponent = services.chat.interfaceDbComponent
            if text:
                result = neutralizationService.processText(text)
            else:
                result = neutralizationService.processFile(fileId)
            if result:
                neutralized = result.get("neutralized_text", "") or result.get("result", str(result))
                return ToolResult(toolCallId="", toolName="neutralizeData", success=True, data=neutralized)
            return ToolResult(toolCallId="", toolName="neutralizeData", success=False, error="Neutralization returned no result")
        except Exception as e:
            return ToolResult(toolCallId="", toolName="neutralizeData", success=False, error=str(e))

    async def _executeCode(args: Dict[str, Any], context: Dict[str, Any]):
        code = args.get("code", "")
        language = args.get("language", "python")
        if not code:
            return ToolResult(toolCallId="", toolName="executeCode", success=False, error="code is required")
        if language != "python":
            return ToolResult(toolCallId="", toolName="executeCode", success=False, error=f"Language '{language}' not supported. Only 'python' is available.")
        try:
            from modules.serviceCenter.services.serviceAgent.sandboxExecutor import executePython
            result = await executePython(code)
            if result.get("success"):
                output = result.get("output", "(no output)")
                return ToolResult(toolCallId="", toolName="executeCode", success=True, data=output)
            error = result.get("error", "Execution failed")
            tb = result.get("traceback", "")
            return ToolResult(toolCallId="", toolName="executeCode", success=False, error=f"{error}\n{tb}" if tb else error)
        except Exception as e:
            return ToolResult(toolCallId="", toolName="executeCode", success=False, error=str(e))

    registry.register(
        "speechToText", _speechToText,
        description="Transcribe an audio file to text using speech recognition. Returns the transcript with confidence score.",
        parameters={
            "type": "object",
            "properties": {
                "fileId": {"type": "string", "description": "Audio file ID from the workspace"},
                "language": {"type": "string", "description": "BCP-47 language code (e.g. 'de-DE', 'en-US'). Default: 'de-DE'"},
            },
            "required": ["fileId"]
        },
        readOnly=True
    )

    registry.register(
        "detectLanguage", _detectLanguage,
        description="Detect the language of a text snippet. Returns ISO 639-1 code (e.g. 'de', 'en').",
        parameters={
            "type": "object",
            "properties": {
                "text": {"type": "string", "description": "Text to analyze"},
            },
            "required": ["text"]
        },
        readOnly=True
    )

    registry.register(
        "neutralizeData", _neutralizeData,
        description="Anonymize text or file content by replacing personal data (names, addresses, etc.) with placeholders. Non-destructive -- returns the anonymized copy.",
        parameters={
            "type": "object",
            "properties": {
                "text": {"type": "string", "description": "Text to anonymize"},
                "fileId": {"type": "string", "description": "File ID to anonymize (alternative to text)"},
            },
        },
        readOnly=True
    )

    registry.register(
        "executeCode", _executeCode,
        description=(
            "Execute Python code in a sandboxed environment for calculations and data analysis. "
            "Available modules: math, statistics, json, csv, re, datetime, collections, itertools, functools, decimal, fractions, random. "
            "No file system, network, or OS access. Max 30s execution time. "
            "Use print() to produce output."
        ),
        parameters={
            "type": "object",
            "properties": {
                "code": {"type": "string", "description": "Python code to execute"},
                "language": {"type": "string", "description": "Programming language (only 'python' supported)", "default": "python"},
            },
            "required": ["code"]
        },
        readOnly=True
    )

    # ---- Feature Data Sub-Agent tool ----

    async def _queryFeatureInstance(args: Dict[str, Any], context: Dict[str, Any]):
        """Delegate a question to the Feature Data Sub-Agent."""
        featureInstanceId = args.get("featureInstanceId", "")
        question = args.get("question", "")
        if not featureInstanceId or not question:
            return ToolResult(
                toolCallId="", toolName="queryFeatureInstance",
                success=False, error="featureInstanceId and question are required",
            )
        try:
            from modules.serviceCenter.services.serviceAgent.featureDataAgent import runFeatureDataAgent
            from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
            from modules.interfaces.interfaceDbApp import getRootInterface

            rootIf = getRootInterface()
            instance = rootIf.getFeatureInstance(featureInstanceId)
            if not instance:
                return ToolResult(
                    toolCallId="", toolName="queryFeatureInstance",
                    success=False, error=f"Feature instance {featureInstanceId} not found",
                )

            featureCode = instance.featureCode
            mandateId = instance.mandateId or ""
            instanceLabel = instance.label or ""
            userId = context.get("userId", "")
            workspaceInstanceId = context.get("featureInstanceId", "")

            rootDbConn = rootIf.db if hasattr(rootIf, "db") else None
            if rootDbConn is None:
                return ToolResult(
                    toolCallId="", toolName="queryFeatureInstance",
                    success=False, error="No database connector available",
                )

            featureDataSources = rootDbConn.getRecordset(
                FeatureDataSource,
                recordFilter={"featureInstanceId": featureInstanceId, "workspaceInstanceId": workspaceInstanceId},
            )

            from modules.security.rbacCatalog import getCatalogService
            catalog = getCatalogService()
            if not featureDataSources:
                selectedTables = catalog.getDataObjects(featureCode)
            else:
                allObjs = {o["meta"]["table"]: o for o in catalog.getDataObjects(featureCode) if "meta" in o and "table" in o.get("meta", {})}
                selectedTables = [allObjs[ds["tableName"]] for ds in featureDataSources if ds.get("tableName") in allObjs]

            if not selectedTables:
                return ToolResult(
                    toolCallId="", toolName="queryFeatureInstance",
                    success=False, error=f"No data tables available for feature '{featureCode}'",
                )

            from modules.connectors.connectorDbPostgre import DatabaseConnector
            from modules.shared.configuration import APP_CONFIG
            featureDbName = f"poweron_{featureCode.lower()}"
            featureDbConn = DatabaseConnector(
                dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
                dbDatabase=featureDbName,
                dbUser=APP_CONFIG.get("DB_USER"),
                dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET"),
                dbPort=int(APP_CONFIG.get("DB_PORT", 5432)),
                userId=userId or "agent",
            )

            aiService = services.ai if hasattr(services, "ai") else None
            if aiService is None:
                return ToolResult(
                    toolCallId="", toolName="queryFeatureInstance",
                    success=False, error="AI service not available for sub-agent",
                )

            async def _subAgentAiCall(req):
                return await aiService.callAi(req)

            try:
                answer = await runFeatureDataAgent(
                    question=question,
                    featureInstanceId=featureInstanceId,
                    featureCode=featureCode,
                    selectedTables=selectedTables,
                    mandateId=mandateId,
                    userId=userId,
                    aiCallFn=_subAgentAiCall,
                    dbConnector=featureDbConn,
                    instanceLabel=instanceLabel,
                )
            finally:
                try:
                    featureDbConn.close()
                except Exception:
                    pass

            return ToolResult(
                toolCallId="", toolName="queryFeatureInstance",
                success=True, data=answer,
            )
        except Exception as e:
            logger.error(f"queryFeatureInstance failed: {e}", exc_info=True)
            return ToolResult(
                toolCallId="", toolName="queryFeatureInstance",
                success=False, error=str(e),
            )

    registry.register(
        "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/query its tables. Use this when the user has attached "
            "feature data sources or asks about feature-specific data."
        ),
        parameters={
            "type": "object",
            "properties": {
                "featureInstanceId": {"type": "string", "description": "ID of the feature instance to query"},
                "question": {"type": "string", "description": "What data to find or analyze from this feature instance"},
            },
            "required": ["featureInstanceId", "question"]
        },
        readOnly=True
    )

    # ---- Cross-workflow tools ----

    async def _listWorkflowHistory(args: Dict[str, Any], context: Dict[str, Any]) -> ToolResult:
        """List all chat workflows in this workspace with metadata."""
        import json as _json
        try:
            chatService = services.chat
            chatInterface = chatService.interfaceDbChat
            allWorkflows = chatInterface.getWorkflows() or []

            allWorkflows.sort(
                key=lambda w: w.get("sysCreatedAt") or w.get("startedAt") or 0,
                reverse=True,
            )
            allWorkflows = allWorkflows[:50]

            items = []
            for wf in allWorkflows:
                wfId = wf.get("id", "")
                name = wf.get("name") or "(unnamed)"
                createdAt = wf.get("sysCreatedAt") or wf.get("startedAt") or 0
                lastActivity = wf.get("lastActivity") or createdAt

                msgs = chatInterface.getMessages(wfId) or []
                messageCount = len(msgs)
                lastPreview = ""
                if msgs:
                    lastMsg = msgs[-1] if isinstance(msgs[-1], dict) else (
                        msgs[-1].model_dump() if hasattr(msgs[-1], "model_dump") else {}
                    )
                    content = lastMsg.get("message") or lastMsg.get("content") or ""
                    lastPreview = content[:150]

                items.append({
                    "id": wfId,
                    "name": name,
                    "createdAt": createdAt,
                    "lastActivity": lastActivity,
                    "messageCount": messageCount,
                    "lastMessagePreview": lastPreview,
                })

            return ToolResult(
                toolCallId="", toolName="listWorkflowHistory",
                success=True, data=_json.dumps(items, ensure_ascii=False),
            )
        except Exception as e:
            return ToolResult(
                toolCallId="", toolName="listWorkflowHistory",
                success=False, error=str(e),
            )

    registry.register(
        "listWorkflowHistory", _listWorkflowHistory,
        description=(
            "List all chat conversations/workflows in this workspace. "
            "Returns id, name, createdAt, lastActivity, messageCount, and a preview "
            "of the last message for each workflow. Use this to discover previous "
            "conversations when the user asks about past chats or wants a summary "
            "across conversations."
        ),
        parameters={
            "type": "object",
            "properties": {},
        },
        readOnly=True,
    )

    async def _readWorkflowMessages(args: Dict[str, Any], context: Dict[str, Any]) -> ToolResult:
        """Read messages from a specific workflow."""
        import json as _json
        targetWorkflowId = args.get("workflowId", "")
        limit = int(args.get("limit", 20))
        offset = int(args.get("offset", 0))

        if not targetWorkflowId:
            return ToolResult(
                toolCallId="", toolName="readWorkflowMessages",
                success=False, error="workflowId is required",
            )

        try:
            chatService = services.chat
            chatInterface = chatService.interfaceDbChat
            allMsgs = chatInterface.getMessages(targetWorkflowId) or []

            sliced = allMsgs[offset:offset + limit]
            items = []
            for msg in sliced:
                raw = msg if isinstance(msg, dict) else (
                    msg.model_dump() if hasattr(msg, "model_dump") else {}
                )
                content = raw.get("message") or raw.get("content") or ""
                if len(content) > 2000:
                    content = content[:2000] + "..."
                items.append({
                    "role": raw.get("role", ""),
                    "message": content,
                    "publishedAt": raw.get("publishedAt") or raw.get("sysCreatedAt") or 0,
                })

            header = f"Workflow {targetWorkflowId}: {len(allMsgs)} total messages"
            if offset > 0 or len(allMsgs) > offset + limit:
                header += f" (showing {offset + 1}-{offset + len(sliced)})"

            return ToolResult(
                toolCallId="", toolName="readWorkflowMessages",
                success=True,
                data=header + "\n" + _json.dumps(items, ensure_ascii=False),
            )
        except Exception as e:
            return ToolResult(
                toolCallId="", toolName="readWorkflowMessages",
                success=False, error=str(e),
            )

    registry.register(
        "readWorkflowMessages", _readWorkflowMessages,
        description=(
            "Read messages from a specific chat workflow/conversation. "
            "Use this after listWorkflowHistory to read the content of a "
            "specific past conversation. Supports pagination via offset/limit."
        ),
        parameters={
            "type": "object",
            "properties": {
                "workflowId": {"type": "string", "description": "ID of the workflow to read messages from"},
                "limit": {"type": "integer", "description": "Max messages to return (default 20)"},
                "offset": {"type": "integer", "description": "Skip first N messages (default 0)"},
            },
            "required": ["workflowId"],
        },
        readOnly=True,
    )
