# Agent-Tool File Bridge ## Worum geht es Ein Agent-Tool kann eine Datei produzieren (Download, generieren, schreiben). Daraus muss zwingend dreierlei werden, sonst sind die nachgelagerten AI-Tools (`ai_process`, `ai_summarizeDocument`, `context_extractContent`, `context_neutralizeData`) blind: 1. ein **`FileItem`** in der Management-DB (Inhalt + Metadaten), 2. ein **`ChatDocument`**, das im aktuellen Workflow auf das `FileItem` verweist und unter `workflow.messages[*].documents[*]` landet, 3. eine **`ChatMessage`**, die das `ChatDocument` traegt (sonst hat es keinen `messageId` und ist nicht referenzierbar). `getChatDocumentsFromDocumentList` (in `mainServiceChat.py`) loest `docItem:`-Referenzen ausschliesslich gegen `ChatDocument.id`-Werte in `workflow.messages[*].documents[*]` auf. **Wenn der Agent nur ein `FileItem` erzeugt, aber kein `ChatDocument`, ist der File-Output fuer jedes documentList-konsumierende Tool unsichtbar.** ## Symptom (vor dem Fix) Klassischer Trace nach `downloadFromDataSource` -> `ai_summarizeDocument`: ``` INFO ai_summarizeDocument called with documentList=[...] WARN getChatDocumentsFromDocumentList: No workflow available (self._workflow is not set) INFO Building structure prompt with 0 valid ContentParts ``` Erstes WARN entsteht, wenn `runAgent` das Workflow nicht in die Service-Contexts propagiert. Zweites Symptom (0 ContentParts) bleibt auch danach, solange Agent-Tools nur ein `FileItem` produzieren ohne `ChatDocument`. ## Architektur-Pattern ```mermaid flowchart LR Tool[Agent Tool\ndownloadFromDataSource\nwriteFile create\nrenderDocument\ngenerateImage\ncreateChart] -->|saveUploadedFile
saveGeneratedFile| FI[FileItem] FI -->|_attachFileAsChatDocument| Bridge((Bridge Helper)) Bridge -->|storeMessageWithDocuments| CM[ChatMessage] CM --> CD[ChatDocument] CD -.fileId.-> FI CD -.id used as.-> Ref{{docItem:<chatDocId>}} Ref -->|documentList parameter| AITools[ai_process
ai_summarizeDocument
context_extractContent
context_neutralizeData] AITools -->|getChatDocumentsFromDocumentList| CD ``` ## Komponenten ### `_attachFileAsChatDocument` (Single Source of Truth) Datei: `platform-core/modules/serviceCenter/services/serviceAgent/coreTools/_helpers.py` ```python def _attachFileAsChatDocument( services: Any, fileItem: Any, *, label: str = "agent_tool_output", userMessage: str = "", role: str = "assistant", ) -> Optional[str]: """Bind a persisted FileItem to the active workflow as a ChatDocument. Returns the new ChatDocument.id (or None if no active workflow). """ ``` Liest `chatService._workflow` (= `chatService._context.workflow`), baut ein `ChatDocument`-Dict mit `roundNumber`/`taskNumber`/`actionNumber` aus dem Workflow, packt es in eine `ChatMessage` (Rolle `assistant`, Status `step`, eindeutiges `documentsLabel`) und persistiert via `chatService.storeMessageWithDocuments`. Wirft nie -- liefert im Fehlerfall `None` und loggt Warning. ### `_formatToolFileResult` (Unified ToolResult Text) Renderkonvention: jedes file-erzeugende Tool gibt im Result-Text **immer beide IDs** raus: ``` Downloaded 'platform-overview.html' (87654 bytes) documentList ref: docItem:abcd-1234-... file id: c7bf161b-8113-4b53-... ``` * `docItem:` -> hineinkopieren in `documentList` von AI-Tools. * `file id: ` -> fuer `readFile`, `searchInFileContent`, `writeFile mode=append`, Bild-Embeds (`![alt](file:)`). Wenn kein aktiver Workflow gebunden ist, faellt die `documentList ref`-Zeile weg -- die Datei ist trotzdem ueber `file id` direkt lesbar. Der documentList-Pfad braucht Workflow-Kontext sowieso. ### Workflow-Propagation in `runAgent` Datei: `platform-core/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py` `runAgent(workflowId=...)` setzt das Workflow-Objekt jetzt in `self.services.workflow` UND in `_context.workflow` aller Services (`chat`, `ai`, `extraction`, `sharepoint`, `clickup`, `utils`, `billing`, `generation`). Spiegel von `workflowManager._propagateWorkflowToContext`. Ohne diesen Schritt ist `chatService._workflow` `None` und der Bridge-Helper greift nie. ## Aufrufstellen | Tool | Datei | Aufruf | |------|-------|--------| | `downloadFromDataSource` | `_dataSourceTools.py` | nach `saveUploadedFile`, `label=datasource:` | | `writeFile mode=create` | `_workspaceTools.py` | nach `saveUploadedFile`, `label=writeFile:` | | `renderDocument` | `_mediaTools.py` | im Multi-Doc-Loop pro generiertem File, `label=renderDocument:` | | `generateImage` | `_mediaTools.py` | im Multi-Image-Loop pro PNG, `label=generateImage:` | | `createChart` | `_mediaTools.py` | nach Chart-PNG-Save, `label=createChart:` | Alle anderen file-konsumierenden Tools (`readFile`, `replaceInFile`, `searchInFileContent`, `listFiles`, `getFileInfo`, ...) brauchen keinen Bridge-Aufruf -- sie veraendern nur Inhalt oder lesen, ohne neuen Workflow-State zu erzeugen. ## Vergleich mit anderen Bridges | Pfad | Helper | Wo | |------|--------|-----| | Workflow-Action mit `ActionResult.documents` | `persistTaskResult` (intern) | `workflowProcessor.py:608-707` | | `methodTrustee.extractFromFiles` (FileItem-IDs aus Sub-Trustee) | inline Pattern | `methodTrustee/actions/extractFromFiles.py:507-545` | | Agent-Tool-Output (NEU) | `_attachFileAsChatDocument` | `coreTools/_helpers.py` | Alle drei produzieren am Ende dieselbe Struktur: `ChatMessage` -> `ChatDocument` -> `fileId`. Der Agent-Pfad war bisher die einzige Luecke. ## Tolerante `documentList`-Parsing-Schicht Komplementaer dazu: `coerceDocumentReferenceList` in `platform-core/modules/datamodels/datamodelDocref.py` parst das, was der LLM am anderen Ende reinschickt -- typischerweise * `["docItem:", "docItem:"]` (kanonisch), * `[{"id": "", "name": "..."}]` (LLM-Listen-Style), * `{"documents": [{"id": ""}, ...]}` (LLM-Dict-Wrapper), * `"docItem:"` (Single-String). Alle vier Shapes werden in `DocumentReferenceList` mit `DocumentItemReference(documentId=)` umgewandelt; daraus baut `getChatDocumentsFromDocumentList` die `docItem:`-Suchschluessel und matched gegen `workflow.messages[*].documents[*].id` -- also exakt gegen die `ChatDocument.id`s, die der Bridge-Helper persistiert. Die Kette ist damit: ``` agent tool -> _attachFileAsChatDocument (writes ChatDocument.id = X) -> _formatToolFileResult (returns "docItem:X" to LLM) -> LLM puts "docItem:X" in documentList (or any tolerant variant) -> coerceDocumentReferenceList (normalises to docItem:X) -> getChatDocumentsFromDocumentList (matches workflow.messages[*].documents[*].id == X) -> ai_summarizeDocument receives ContentParts ``` ## Failure Modes | Symptom | Ursache | Wo schauen | |---------|---------|------------| | `getChatDocumentsFromDocumentList: No workflow available` | `chatService._context.workflow` ist `None` | `mainServiceAgent.runAgent` -- propagiert es das Workflow? | | "Building structure prompt with 0 valid ContentParts" trotz erfolgreichem Download | Bridge nicht aufgerufen oder Workflow None | Pruefen ob `_attachFileAsChatDocument` `None` returnt | | `Invalid documentList type: ` | Tolerant-Coercer wurde umgangen | Action benutzt nicht `coerceDocumentReferenceList` | | `'dict' object has no attribute 'fileName'` aus `listFiles` | `getAllFiles` returnt dicts, Code greift mit `.attribute` | `mainServiceChat.listFiles` muss `.get(...)` benutzen | | `File with ID ... not found` direkt nach `Duplicate detected for user ...` | "Ghost duplicate" -- `checkForDuplicateFile` returned ein RBAC-unsichtbares File. Wurzel: `interfaceDbComponent` ohne `featureInstanceId` initialisiert, Duplicate-Suche faellt auf mandate-Scope zurueck. | `interfaceDbManagement.checkForDuplicateFile` macht jetzt einen `getFile`-Cross-Check; wenn `getFile None`, gilt als kein Duplicate -> Caller erstellt frische per-Scope-Kopie. Fix in 2026-04-29. | ## Ghost-Duplicate-Fix (2026-04-29) Symptom des Tages: `downloadFromDataSource` returnte zuverlaessig `File with ID 21b0b995-... not found`, **direkt nach** einer `Duplicate detected for user ...` INFO-Zeile aus `saveUploadedFile`. Mechanik: * `interfaceDbComponent` wird ueber `serviceHub` ohne `featureInstanceId` konstruiert -- nur `mandateId` wird durchgereicht. * `checkForDuplicateFile` baute bisher den `recordFilter` mit `if self.featureInstanceId: ... elif self.mandateId: ...` und benutzte `db.getRecordset` (kein RBAC-Filter). Foglich matchte er ein File aus einer **anderen** featureInstance, solange Hash + Name + sysCreatedBy + Mandate uebereinstimmten. * `getFile` benutzt aber `getRecordsetWithRBAC` und filtert das fremd-scope File raus -> `None`. * Caller (`saveUploadedFile.updateFile(...)`) crashed mit `File with ID ... not found`. Sauberer Fix in `checkForDuplicateFile`: nach dem Recordset-Treffer zwingend ein `self.getFile(fileId)`-Cross-Check. Wenn RBAC blockiert, returnen wir `None` -> der Caller erstellt eine frische per-Scope-Kopie. Damit entfaellt das Geister-Duplicate komplett, ohne Workaround in `updateFile` und ohne `interfaceDbComponent` umzuverdrahten. Folge: identische Files koennen pro Mandate **mehrfach** existieren (eines pro featureInstance) -- das ist gewollt, weil sie pro Scope eigene Folder-/Tag-/Neutralize-Metadaten brauchen. ## Auch wichtig * Der RBAC-Check in `interfaceDbChat.createMessage` (`checkRbacPermission(ChatWorkflow, "update", workflowId)`) gilt weiter -- ohne Update-Permission auf den Workflow kein ChatDocument-Bind. Gewollt. * `chatService.storeMessageWithDocuments` synchronisiert auch das in-Memory-Workflow-Objekt (haengt die neue ChatMessage an `workflow.messages` an), so dass der gleiche Run sofort darauf zugreifen kann. * Das `documentsLabel` (`label`-Argument im Helper) wird vom alternativen `docList: