diff --git a/TOPICS.md b/TOPICS.md index b2226cd..7fb01ea 100644 --- a/TOPICS.md +++ b/TOPICS.md @@ -20,6 +20,7 @@ Lade immer zuerst diese Datei. Dann gezielt die passende(n) Referenz-Datei(en). | Komponentenübersicht | b-reference/product.md | Repo-übergreifende Fragen, Tech-Stack | | Gateway-Architektur | b-reference/gateway/architecture.md | Backend-Module, Services, Interfaces | | AI Agent & Tools | b-reference/gateway/ai-agent.md | Agent-Verhalten, Tool-Registrierung, RAG | +| Agent-Tool File Bridge | b-reference/gateway/agent-file-bridge.md | Wie Agent-Tool-Outputs (download / writeFile / renderDocument / generateImage / createChart) als ChatDocument im Workflow landen, `docItem:`-Pattern, runAgent Workflow-Propagation | | Workflow-Engine | b-reference/gateway/workflow.md | Methoden, Aktionen, WorkflowManager | | Automation | b-reference/gateway/automation.md | Graphical Editor, Scheduler, System-Automatisierung (`/automations`, `/api/system/workflow-runs/*`) | | Billing & Subscriptions | b-reference/gateway/billing.md | Abrechnung, Prepaid, State Machine | diff --git a/b-reference/gateway/agent-file-bridge.md b/b-reference/gateway/agent-file-bridge.md new file mode 100644 index 0000000..d2dba3d --- /dev/null +++ b/b-reference/gateway/agent-file-bridge.md @@ -0,0 +1,220 @@ + + + + +# 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: `gateway/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: `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:` | +| `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 +`gateway/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: