10 KiB
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:
- ein
FileItemin der Management-DB (Inhalt + Metadaten), - ein
ChatDocument, das im aktuellen Workflow auf dasFileItemverweist und unterworkflow.messages[*].documents[*]landet, - eine
ChatMessage, die dasChatDocumenttraegt (sonst hat es keinenmessageIdund ist nicht referenzierbar).
getChatDocumentsFromDocumentList (in mainServiceChat.py) loest
docItem:<id>-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
flowchart LR
Tool[Agent Tool\ndownloadFromDataSource\nwriteFile create\nrenderDocument\ngenerateImage\ncreateChart] -->|saveUploadedFile<br/>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<br/>ai_summarizeDocument<br/>context_extractContent<br/>context_neutralizeData]
AITools -->|getChatDocumentsFromDocumentList| CD
Komponenten
_attachFileAsChatDocument (Single Source of Truth)
Datei: gateway/modules/serviceCenter/services/serviceAgent/coreTools/_helpers.py
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:<chatDocId>-> hineinkopieren indocumentListvon AI-Tools.file id: <fileId>-> fuerreadFile,searchInFileContent,writeFile mode=append, Bild-Embeds ().
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: gateway/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:<service> |
writeFile mode=create |
_workspaceTools.py |
nach saveUploadedFile, label=writeFile:<name> |
renderDocument |
_mediaTools.py |
im Multi-Doc-Loop pro generiertem File, label=renderDocument:<name> |
generateImage |
_mediaTools.py |
im Multi-Image-Loop pro PNG, label=generateImage:<name> |
createChart |
_mediaTools.py |
nach Chart-PNG-Save, label=createChart:<name> |
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
gateway/modules/datamodels/datamodelDocref.py parst das, was der LLM
am anderen Ende reinschickt -- typischerweise
["docItem:<id>", "docItem:<id2>"](kanonisch),[{"id": "<id>", "name": "..."}](LLM-Listen-Style),{"documents": [{"id": "<id>"}, ...]}(LLM-Dict-Wrapper),"docItem:<id>"(Single-String).
Alle vier Shapes werden in DocumentReferenceList mit
DocumentItemReference(documentId=<id>) umgewandelt; daraus baut
getChatDocumentsFromDocumentList die docItem:<id>-Suchschluessel
und matched gegen workflow.messages[*].documents[*].id -- also exakt
gegen die ChatDocument.ids, 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: <class 'dict'> |
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:
interfaceDbComponentwird ueberserviceHubohnefeatureInstanceIdkonstruiert -- nurmandateIdwird durchgereicht.checkForDuplicateFilebaute bisher denrecordFiltermitif self.featureInstanceId: ... elif self.mandateId: ...und benutztedb.getRecordset(kein RBAC-Filter). Foglich matchte er ein File aus einer anderen featureInstance, solange Hash + Name + sysCreatedBy- Mandate uebereinstimmten.
getFilebenutzt abergetRecordsetWithRBACund filtert das fremd-scope File raus ->None.- Caller (
saveUploadedFile.updateFile(...)) crashed mitFile 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.storeMessageWithDocumentssynchronisiert auch das in-Memory-Workflow-Objekt (haengt die neue ChatMessage anworkflow.messagesan), so dass der gleiche Run sofort darauf zugreifen kann.- Das
documentsLabel(label-Argument im Helper) wird vom alternativendocList:<label>-Resolver benutzt, der mehrere Files ueber ein gemeinsames Label sammelt -- nicht primaerer Pfad, aber vorhanden.