220 lines
10 KiB
Markdown
220 lines
10 KiB
Markdown
<!-- status: canonical -->
|
|
<!-- lastReviewed: 2026-04-29 -->
|
|
<!-- verifiedAgainst: gateway/modules/serviceCenter/services/serviceAgent/coreTools/_helpers.py (_attachFileAsChatDocument helper); _dataSourceTools.py / _workspaceTools.py / _mediaTools.py (5 file-producing agent tools); mainServiceAgent.py runAgent workflow propagation; mainServiceChat.py (storeMessageWithDocuments + getChatDocumentsFromDocumentList); workflowProcessor.persistTaskResult (canonical pattern); methodTrustee.extractFromFiles (peer pattern); datamodelChat.ChatDocument; datamodelDocref.coerceDocumentReferenceList -->
|
|
|
|
# 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:<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
|
|
|
|
```mermaid
|
|
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`
|
|
|
|
```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:<chatDocId>` -> hineinkopieren in `documentList` von AI-Tools.
|
|
* `file id: <fileId>` -> fuer `readFile`, `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.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: <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:
|
|
|
|
* `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:<label>`-Resolver benutzt, der mehrere Files
|
|
ueber ein gemeinsames Label sammelt -- nicht primaerer Pfad, aber
|
|
vorhanden.
|