Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .agents/skills/deepchat-sdd/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ Resolve every `[NEEDS CLARIFICATION]` marker before implementation. If a request
5. Implement the change after the SDD artifacts are complete.
6. Update `tasks.md` as work lands.
7. Run `pnpm run format`, `pnpm run i18n`, and `pnpm run lint` before handoff.
8. After implementation is accepted and validation passes, delete `plan.md` and `tasks.md` for
that goal; keep `spec.md` as the durable contract.

## Documentation Hygiene

Expand Down
73 changes: 73 additions & 0 deletions docs/issues/file-attachment-token-bloat/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# File Attachment Token Bloat Spec

> Status: Implemented
> Date: 2026-07-02
> Related: https://github.com/ThinkInAIXYZ/deepchat/issues/1864

## Problem

DeepChat currently treats chat input files as both UI attachments and model-ready context. For
non-image files, `file.prepareFile` returns LLM-friendly `content`, the user message can store that
content, and the agent runtime context builder can replay it into every later request while the
message remains inside history. For images, uploaded/pasted files are represented with image data
URLs so vision models can see them, but historical image turns can also keep resending large inline
image payloads. Audio is different from document files: when the selected model supports native
audio input, keeping audio as structured base64 input is intentional model behavior, not a text
attachment fallback.

Issue #1864 reports the visible failure mode: attaching an image, PDF, or document makes later API
calls resend the old file data, increasing request size, cache misses, token use, and latency.

## User Need

Users need file attachments to remain visually correct in the conversation while keeping later model
requests lean. The model should still receive enough path and metadata context to read local files
through tools instead of embedding their contents into chat history.

## Goals

- Keep image attachments as message media for the submitted turn so vision behavior remains correct.
- Avoid resending historical file bytes or extracted document text on unrelated follow-up turns.
- Route non-image file understanding through existing filesystem tooling when the agent needs the
content.
- Preserve native audio input for models that support audio attachments.
- Keep message rendering stable: existing attachment chips, thumbnails, names, paths, and metadata
stay visible in the transcript.

## Acceptance Criteria

- A newly submitted image attachment is sent as an image part when the active model supports vision.
- Historical image attachments are not resent as inline image data by default on later unrelated
turns; history includes enough metadata/path context for the model to request a read when tools are
available.
- Non-image attachments are replayed as path plus metadata by default, not full extracted content.
- Existing `agent-filesystem.read` remains the primary path for reading text, PDF, Office, and other
supported non-image files during an agent turn.
- PDF, Word, Excel, PowerPoint, text, and code files continue to rely on the existing
`FilePresenter.prepareFileCompletely(..., 'llm-friendly')` pipeline behind
`agent-filesystem.read`.
- Audio attachments still become structured `input_audio` parts when the model supports audio input.
- Attachment chips do not need a separate path insertion action; path/metadata is included
automatically in the model-visible attachment context.
- Existing sessions with stored attachment content do not require a database migration; request
construction strips or ignores stale inline content where needed.

## Non-Goals

- Redesigning the full attachment data model in one step.
- Replacing `agent-filesystem.read` with a new file reading tool.
- Automatically invoking file reads before every provider call.
- Deleting attachment content from existing user databases during this change.
- Removing structured audio input for audio-capable models.
- Solving provider-specific cache-key behavior beyond stopping repeated inline payloads.

## Constraints

- Reuse existing main-process tools and permission flow for file reads.
- Keep privileged filesystem access behind preload/typed route clients.
- Keep UI copy in i18n keys when implementation starts.
- Keep the first implementation small enough to review as a focused issue fix.

## Open Questions

None.
112 changes: 79 additions & 33 deletions src/main/presenter/agentRuntimePresenter/contextBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ type TokenizedTurn = {
tokens: number
}

type UserMessageContentBuildOptions = {
includeFileContent?: boolean
includeImageData?: boolean
includeAudioData?: boolean
}

export type HistoryTurn = {
records: ChatMessageRecord[]
messages: ChatMessage[]
Expand Down Expand Up @@ -208,28 +214,44 @@ export function isContextHistoryRecord(record: ChatMessageRecord): boolean {
return record.role === 'assistant' && record.status === 'error'
}

function buildNonImageFileContext(files: MessageFile[], excludeAudio: boolean = false): string {
function buildNonImageFileContext(
files: MessageFile[],
options: {
excludeAudio?: boolean
includeFileContent?: boolean
} = {}
): string {
const nonImageFiles = files.filter(
(file) => !isImageFile(file) && (!excludeAudio || !isAudioFile(file))
(file) => !isImageFile(file) && (!options.excludeAudio || !isAudioFile(file))
)
if (nonImageFiles.length === 0) {
return ''
}

const blocks = nonImageFiles.map((file, index) => {
const isAudio = isAudioFile(file)
const fileName = typeof file.name === 'string' ? file.name : `file-${index + 1}`
const filePath = typeof file.path === 'string' ? file.path : ''
const mimeType = resolveFileMimeType(file)
const fileContent = typeof file.content === 'string' ? file.content : ''
const shouldIncludeContent =
options.includeFileContent === true ||
(isAudio && !fileContent.trim().toLowerCase().startsWith('data:audio/'))
const byteSize = resolveFileByteSize(file)
const metadataLines = [
`name: ${fileName}`,
filePath ? `path: ${filePath}` : '',
mimeType ? `mime: ${mimeType}` : ''
mimeType ? `mime: ${mimeType}` : '',
byteSize ? `size: ${byteSize}` : ''
]
.filter(Boolean)
.join('\n')
if (!fileContent.trim()) {
return `[Attached File ${index + 1}]\n${metadataLines}\ncontent: [empty]`
const placeholder = filePath ? '[omitted; use read if needed]' : '[empty]'
return `[Attached File ${index + 1}]\n${metadataLines}\ncontent: ${placeholder}`
}
if (!shouldIncludeContent) {
return `[Attached File ${index + 1}]\n${metadataLines}\ncontent: [omitted; use read if needed]`
}
return `[Attached File ${index + 1}]\n${metadataLines}\ncontent:\n${fileContent}`
})
Expand Down Expand Up @@ -383,10 +405,13 @@ function buildImageMetadataContext(files: MessageFile[]): string {
export function buildUserMessageContent(
input: SendMessageInput,
supportsVision: boolean,
supportsAudioInput: boolean = false
supportsAudioInput: boolean = false,
options: UserMessageContentBuildOptions = {}
): ChatMessage['content'] {
const text = input.text ?? ''
const files = Array.isArray(input.files) ? input.files : []
const includeImageData = options.includeImageData !== false
const includeAudioData = options.includeAudioData !== false

const imageFiles = files.filter((file) => isImageFile(file))
const audioFiles = files.filter((file) => isAudioFile(file))
Expand All @@ -398,37 +423,44 @@ export function buildUserMessageContent(
filename?: string
estimated_tokens?: number
}
}> = supportsAudioInput
? audioFiles.flatMap((file) => {
const payload = resolveAudioAttachmentPayload(file)
if (!payload) {
return []
}
}> =
supportsAudioInput && includeAudioData
? audioFiles.flatMap((file) => {
const payload = resolveAudioAttachmentPayload(file)
if (!payload) {
return []
}

return [
{
type: 'input_audio' as const,
input_audio: {
data: payload.data,
media_type: payload.mediaType,
...(typeof file.name === 'string' && file.name.trim() ? { filename: file.name } : {}),
estimated_tokens: estimateAudioInputTokens(file, payload.byteLength)
return [
{
type: 'input_audio' as const,
input_audio: {
data: payload.data,
media_type: payload.mediaType,
...(typeof file.name === 'string' && file.name.trim()
? { filename: file.name }
: {}),
estimated_tokens: estimateAudioInputTokens(file, payload.byteLength)
}
}
}
]
})
: []
]
})
: []

const excludeAudioFromFallback = supportsAudioInput && audioParts.length > 0
const nonImageContext = buildNonImageFileContext(files, excludeAudioFromFallback)
const nonImageContext = buildNonImageFileContext(files, {
excludeAudio: excludeAudioFromFallback,
includeFileContent: options.includeFileContent === true
})
const audioMetadata = excludeAudioFromFallback ? buildAudioMetadataContext(audioFiles) : ''
const baseText = [text, nonImageContext, audioMetadata]
const shouldBuildImageParts = supportsVision && includeImageData && imageFiles.length > 0
const imageMetadata = shouldBuildImageParts ? '' : buildImageMetadataContext(imageFiles)
const baseText = [text, nonImageContext, audioMetadata, imageMetadata]
.filter((value) => value.trim())
.join('\n\n')

if ((!supportsVision || imageFiles.length === 0) && audioParts.length === 0) {
const imageMetadata = buildImageMetadataContext(imageFiles)
return [baseText, imageMetadata].filter((value) => value.trim()).join('\n\n')
return baseText
}

const parts: Array<
Expand All @@ -450,7 +482,7 @@ export function buildUserMessageContent(
image_url: { url: string; detail?: 'auto' | 'low' | 'high' }
}> = []

if (supportsVision) {
if (supportsVision && includeImageData) {
for (const file of imageFiles) {
const primaryData = typeof file.content === 'string' ? file.content : ''
const fallbackData = typeof file.thumbnail === 'string' ? file.thumbnail : ''
Expand All @@ -466,12 +498,18 @@ export function buildUserMessageContent(
}

const hasStructuredParts = imageParts.length > 0 || audioParts.length > 0
const structuredText = [
baseText,
shouldBuildImageParts && imageParts.length === 0 ? buildImageMetadataContext(imageFiles) : ''
]
.filter((value) => value.trim())
.join('\n\n')
if (!hasStructuredParts) {
const imageMetadata = buildImageMetadataContext(imageFiles)
return [baseText, imageMetadata].filter((value) => value.trim()).join('\n\n')
return structuredText
}

const textPart = baseText || buildStructuredAttachmentText(imageParts.length, audioParts.length)
const textPart =
structuredText || buildStructuredAttachmentText(imageParts.length, audioParts.length)
parts.push({ type: 'text', text: textPart })
parts.push(...imageParts, ...audioParts)

Expand All @@ -486,7 +524,11 @@ export function createUserChatMessage(
const normalizedInput = normalizeUserInput(input)
return {
role: 'user',
content: buildUserMessageContent(normalizedInput, supportsVision, supportsAudioInput)
content: buildUserMessageContent(normalizedInput, supportsVision, supportsAudioInput, {
includeImageData: true,
includeAudioData: true,
includeFileContent: false
})
}
}

Expand Down Expand Up @@ -639,7 +681,11 @@ export function recordToChatMessages(
const parsed = parseUserRecordContent(record.content)
const message: ChatMessage = {
role: 'user',
content: buildUserMessageContent(parsed, supportsVision, supportsAudioInput)
content: buildUserMessageContent(parsed, supportsVision, supportsAudioInput, {
includeImageData: false,
includeAudioData: true,
includeFileContent: false
})
}
return hasPromptMessageContent(message) ? [message] : []
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ describe('CompactionService', () => {
vi.clearAllMocks()
})

it('preserves file bodies and image metadata in user summary blocks', async () => {
it('preserves attachment metadata without replaying file bodies in user summary blocks', async () => {
const { service, messageStore } = createService()

messageStore.getMessages.mockReturnValue([
Expand Down Expand Up @@ -247,7 +247,10 @@ describe('CompactionService', () => {
})

expect(intent).not.toBeNull()
expect(intent?.summaryBlocks[0]).toContain('Detailed file body')
expect(intent?.summaryBlocks[0]).toContain('[Attached File 1]')
expect(intent?.summaryBlocks[0]).toContain('path: /tmp/spec.md')
expect(intent?.summaryBlocks[0]).toContain('content: [omitted; use read if needed]')
expect(intent?.summaryBlocks[0]).not.toContain('Detailed file body')
expect(intent?.summaryBlocks[0]).toContain('[Attached Image 1]')
expect(intent?.summaryBlocks[0]).not.toContain('data:image/png')
})
Expand Down
Loading