From fbf3c4946460e304786b909a5d82cb0feb09ed34 Mon Sep 17 00:00:00 2001 From: "Maksym Hryzodub [DREAM]" Date: Thu, 3 Sep 2026 17:43:56 +0300 Subject: [PATCH] fix(bridle): tell the model a binary attachment exists (CLEAN-57) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime drops file parts before the model call, so a binary attachment (PDF, Office) was invisible to the agent — it would deny seeing the file the person just sent, and an attachment-only message reached the model as an empty turn. The expansion now inlines a short notice naming the file, its type and size, and stating the contents are not readable — same mechanism the text-file inlining already uses. Co-Authored-By: Claude Opus 4.7 --- .../bridle/domain/attachment.service.spec.ts | 36 ++++++++++++++++--- .../bridle/domain/attachment.service.ts | 20 +++++++++++ 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/api/src/slices/bridle/domain/attachment.service.spec.ts b/api/src/slices/bridle/domain/attachment.service.spec.ts index 8095220..9eeb939 100644 --- a/api/src/slices/bridle/domain/attachment.service.spec.ts +++ b/api/src/slices/bridle/domain/attachment.service.spec.ts @@ -237,8 +237,11 @@ describe('BridleAttachmentService — text extraction', () => { expect(out.attachments[0].kind).toBe(BridleAttachmentKinds.Binary); expect(out.attachments[0].readableByAgent).toBe(false); - expect(out.text).toBe('read this'); - expect(out.text).not.toContain('Attached file'); + // Downgraded to binary: the model gets the unreadable-file notice, and + // none of the undecodable bytes reach the prompt as mojibake. + expect(out.text).toContain('read this'); + expect(out.text).toContain('not readable'); + expect(out.text).not.toContain('�'); }); it('downgrades a text-typed file containing NUL bytes', async () => { @@ -253,7 +256,9 @@ describe('BridleAttachmentService — text extraction', () => { const out = await service.expand(AGENT, 'read this', ['t1']); expect(out.attachments[0].kind).toBe(BridleAttachmentKinds.Binary); - expect(out.text).toBe('read this'); + expect(out.text).toContain('read this'); + expect(out.text).toContain('not readable'); + expect(out.text).not.toContain('ab'); }); it('preserves non-ASCII text intact', async () => { @@ -302,7 +307,7 @@ describe('BridleAttachmentService — expansion to parts', () => { expect(out.text).toBe('what is this?'); }); - it('turns a binary into a file part and inlines nothing', async () => { + it('turns a binary into a file part and inlines an unreadable-file notice', async () => { const { service, gw } = makeService(); gw.seed('b1', { name: 'report.pdf', @@ -318,7 +323,28 @@ describe('BridleAttachmentService — expansion to parts', () => { name: 'report.pdf', mimeType: 'application/pdf', }); - expect(out.text).toBe('read it'); + // The runtime drops file parts before the model call, so without this + // notice the model would never learn the file exists at all. + expect(out.text).toContain('read it'); + expect(out.text).toContain('report.pdf'); + expect(out.text).toContain('not readable'); + // Never the contents — only the reference. + expect(out.text).not.toContain('%PDF-1.7'); + }); + + it('gives an attachment-only binary message a visible text body', async () => { + const { service, gw } = makeService(); + gw.seed('b1', { + name: 'haha.xlsx', + mimeType: + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + size: 2, + body: Buffer.from('PK'), + }); + + const out = await service.expand(AGENT, '', ['b1']); + + expect(out.text).toContain('haha.xlsx'); }); it('emits a file part AND inlined text for a text attachment', async () => { diff --git a/api/src/slices/bridle/domain/attachment.service.ts b/api/src/slices/bridle/domain/attachment.service.ts index 4e7b327..06e0d0f 100644 --- a/api/src/slices/bridle/domain/attachment.service.ts +++ b/api/src/slices/bridle/domain/attachment.service.ts @@ -150,6 +150,12 @@ export class BridleAttachmentService { }); if (kind === BridleAttachmentKinds.Text) { textBlocks.push(this.inlineTextBlock(stored)); + } else { + // The runtime drops file parts before the model call, so without + // this line the model never learns the file exists — it would deny + // seeing an attachment the person is looking right at. The notice + // names the file and its limits; the contents stay unread. + textBlocks.push(BridleAttachmentService.binaryNoticeBlock(stored)); } } @@ -210,6 +216,20 @@ export class BridleAttachmentService { return `[Attached file: ${stored.name}]\n${fence}\n${body}${notice}\n${fence}`; } + /** + * What the model is told about a binary attachment: name, type, size — + * and that the contents are out of reach, so it answers honestly instead + * of denying the file exists. + */ + static binaryNoticeBlock(stored: IBridleStoredAttachment): string { + return ( + `[Attached file: ${stored.name} ` + + `(${stored.mimeType}, ${stored.size.toLocaleString('en-US')} bytes). ` + + `Its contents are not readable in this chat — it is delivered as a ` + + `named reference only.]` + ); + } + /** Path of the authenticated download route — never an S3 URL. */ static urlFor(agentId: string, attachmentId: string): string { return `/api/agent/${encodeURIComponent(agentId)}/attachment/${attachmentId}`;