Skip to content

🧲 feat: Automatic Reading Policy for Unified Attachments - #16848

Open
usnavy13 wants to merge 4 commits into
LibreChat-AI:devfrom
usnavy13:feat/unified-upload-routing
Open

usnavy13 wants to merge 4 commits into
LibreChat-AI:devfrom
usnavy13:feat/unified-upload-routing

Conversation

@usnavy13

@usnavy13 usnavy13 commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

With unified uploads a user attaches a file once, but LibreChat still decides what happens to it from the file type alone. A spreadsheet is text-extracted into the prompt at upload even when Run Code is enabled, a document the provider rejects for size is filtered out of the turn before File Search or Run Code could take it, and the model is never told how an attachment was actually read. #16618 reports the spreadsheet case; the broader request is #10941.

It triggers whenever an agent conversation with Run Code or File Search enabled receives a workbook, a CSV, or a PDF above the provider's effective size limit, through the attach button, drag and drop, file paste, or an existing file.

This adds an opt-in llmDeliveryPolicy: automatic under fileConfig (global, endpoints.default, or a provider or custom endpoint name); the default classic keeps today's routing. The policy decides only on turns where a file tool is loaded, Run Code or File Search, whether from the agent builder or the message bar; a conversation that attaches no tool keeps today's classic routing and limits unchanged, truncated text included. On such a turn each attachment's reader comes from the file category, the current provider's existing validators and limits, and the tools actually loaded. Spreadsheets (xlsx, xls, ods, csv, tsv, parquet) go to Run Code when it is available, with no upload-time extraction. Other documents go to the provider within its limits, then complete extracted text when it fits, then File Search, then Run Code; a document too large to send directly tries File Search and Run Code before text. Explicit destinations, legacyFileUploadUX and defaultLLMDeliveryPath overrides still win. Code access is independent of the primary reader: a PDF the model reads natively is also listed for Run Code at a stable path, so the model can edit it in code without reading it again, and the sandbox copy is made only when code first runs.

After the change the model receives a compact per-turn inventory stating how each attachment can be read on that turn and which it cannot read, text is derived from the retained original only when a reader needs it and cached on the record, and a pending File Search file is advertised and indexed before the first search. Nothing changes in the UI: file chips, previews and messages render as they do today, with one exception called out under screenshots.

Related to #16618
Related to #16560
Related to #15420

How it works

Runtime flow:

processAgentFileUpload
  decideUploadReading          # classic route, or a deferred marker for a code-preferred spreadsheet
  acquireUploadText            # only when the chosen route needs text at upload
initializeAgent
  resolveTurnDeliveryRouting   # policy, endpoint file config, provider
  buildTurnReadingContext      # native / search / code evidence, text budget, lazy derivation
  decideFileReading            # per file: provider → text → search → code, with the category's fallbacks
  collectInventoryEntries      # the model's "how you can read them on this turn" list
provisionFiles                 # lazy copy to Run Code or index for File Search, reused on retry

Ownership:

packages/data-provider/src/
├── reading.ts                    # reader kinds, categories, fallback order, TextDerivation marker
├── resolve-llm-delivery-path.ts  # decideFileReading, classic precedence, config gates
└── file-config.ts                # llmDeliveryPolicy schema and resolution
packages/api/src/files/reading/
├── upload.ts                     # upload-time decision, deferred and failed markers
├── turn.ts                       # per-request reading context: evidence, budget, derivation memo
├── derive.ts                     # text derived from the retained original with the built-in extractors
├── native.ts                     # provider rejections admitted to the next reader
├── inventory.ts                  # model-facing inventory
└── history.ts, settle.ts, diagnostics.ts
packages/data-schemas             # file.metadata.textDerivation, saveFileTextDerivation
client/src/components/Chat/Messages/Content/preview.ts              # preview-kind fix only

Type of change

  • Bug fix
  • Feature
  • Tests / tooling / CI

Testing

  1. Set fileConfig.llmDeliveryPolicy: automatic in librechat.yaml (globally or for one endpoint) and start LibreChat with Agents, Run Code and File Search enabled.
  2. In an agent chat, enable Run Code, attach a multi-sheet xlsx and ask for the totals. The system message lists the workbook for Run Code and carries no cells, and the code sandbox receives the original bytes.
  3. With Run Code still on, attach a small PDF alongside the workbook. The model receives the PDF as a native document part, its inventory line adds that Run Code can also open it, and the sandbox receives the PDF's bytes only once code runs.
  4. Attach a PDF above the endpoint's fileSizeLimit (uploaded where it fits, then switch to the smaller endpoint) with File Search enabled. The system message lists it for File Search as too large to send directly, and the file is indexed before the first search.
  5. Remove Run Code from the agent and send again with the workbook. Its text is derived once from the stored original, delivered exactly as classic routing delivers extracted text, reused on later turns, and the preview shows the worksheets after a reload while download returns the original.
  6. With no file tool loaded, or on an endpoint left on classic, every upload behaves as it does on dev, including the truncated text a long CSV gets.

Tested environments/configuration:

  • Browser: Chromium (Playwright, headless) against the credential-free mock harness (fake model, fake code API, fake RAG API, MongoDB memory server)
  • Provider/model: Mock Auto Provider and Mock Auto Small Provider (llmDeliveryPolicy: automatic, the latter with fileSizeLimit: 1), Mock Provider C (classic)
  • Database: MongoDB (memory server for e2e and data-schemas tests)
  • OS: Linux

Automated tests:

  • npx playwright test --config=e2e/playwright.config.mock.ts automatic-upload.spec.ts unified-upload.spec.ts file-provisioning.spec.ts run-files-delivery.spec.ts — 21 passed (10 automatic-upload scenarios, including the no-tool classic case and the entry-point equivalence check, plus the unified-upload, file-provisioning and run-files-delivery lanes)
  • packages/api: npx jest src/files src/agents — 7,418 passed, 13 skipped (the full src/files and src/agents trees)
  • api: Jest over BaseClient, the agent controllers and their specs, file processing, ToolService, the files route and agent initialization — 1,683 passed, 1 skipped (pre-existing)
  • packages/data-schemas: npx jest src/methods/file.spec.ts — 115 passed
  • packages/data-provider: npx jest src/resolve-llm-delivery-path.spec.ts src/reading.spec.ts src/messages.spec.ts src/file-config.spec.ts — 566 passed
  • client: Jest over the message-content, preview, thread, regenerate, equivalence and error suites — 1,243 passed
  • npx tsc --noEmit in packages/api, packages/data-provider, packages/data-schemas and client — clean
  • npm run static-checks -- --against origin/dev — ESLint and Prettier clean
  • All of the above were run on the final tree, which sits on dev at e1dfc10. A headless browser pass confirmed the file chips render identically on automatic and classic endpoints and that a code-kept workbook's preview shows the unavailable state. dev at that commit carries three lane-recording tests in ToolService.spec.js that still stub a function 🏝️ fix: Apply the No-Workspace Decision Wherever an Agent's Code Route Resolves #16798 removed from the execution-time loader; since this branch touches that spec, they are adapted here to the shared code rule so the file lints and passes.
  • Not rerun in the final pass: npm run lighthouse (the earlier run on this branch, with 250 ms per query, measured median LCP 3,962 ms, CLS 0.0166 and TBT 221 ms, all within budget). No live provider or real code sandbox was used; every code receipt comes from the fake code server.

Screenshots / recordings

No user-facing change, with one exception. The file preview dialog treated every MIME type containing xml as text, so a workbook kept for Run Code, which has no extracted text on its record, opened as its raw ZIP bytes. Only XML proper and +xml types preview as text now, and such a workbook shows the existing "Preview not available" state. Dev masks this today because classic routing always extracts workbook text; the fix is one line in preview.ts. Both captures are the same dialog for the same upload, with and without that line.

Before After
Before After

Risk / compatibility

  • Schema: a new optional metadata.textDerivation subdocument on file records (deferred, complete or failed), written only under the automatic policy; no migration. Before downgrading the server, set the policy back to classic so no new deferred records are written; workbooks already deferred stay readable only by Run Code on the older version until re-uploaded (see librechat.example.yaml).
  • Configuration: llmDeliveryPolicy defaults to classic; enable it only after every replica runs this version. legacyFileUploadUX forces classic. The policy engages only on turns that load Run Code or File Search, so a deployment that enables it sees no change in conversations without a file tool; there, a long file follows dev's behavior exactly, including the context-window error a small-context model gives when extracted text does not fit.
  • Two interpretations for the maintainer to confirm: under the automatic policy, on a turn with a file tool loaded, an endpoint fileSizeLimit is treated as native delivery capacity rather than admission, so a file admitted under another endpoint can reach Run Code or File Search preparation here where classic dropped it from the turn (only serverFileSizeLimit bounds those preparations); and a resumed run charges checkpoint files by the resuming agent's own decision, so a tool set that changed while paused can under-count text already in the checkpoint.
  • Performance: text derivation runs at most once per request and is cached on the record, including for documents the provider could not take, so they are no longer re-downloaded and re-parsed on every turn.
  • Security: provider payloads, parser errors and storage paths never reach the model-facing inventory; the one new user-facing string is the retryable File Search preparation error, a stable code mapped to localized copy.

Checklist

  • I reviewed my own changes
  • Relevant tests have been added or updated
  • Existing relevant tests pass
  • The change does not introduce new warnings or errors
  • User-facing or complex behavior is documented where necessary
  • Required dependency changes have been merged/published
  • Required documentation PR: N/A (the configuration key is documented in librechat.example.yaml)

Adds an opt-in `llmDeliveryPolicy: automatic` under `fileConfig`
(global, `endpoints.default`, or per endpoint); the default `classic`
keeps today's routing. On a turn that loads Run Code or File Search, the
policy decides each attachment's reader from its category, the current
provider's existing validators and limits, and the tools actually loaded:
spreadsheets (xlsx, xls, ods, csv, tsv, parquet) go to Run Code with no
upload-time extraction; other documents go to the provider within its
limits, then complete extracted text when it fits, then File Search, then
Run Code; a document too large to send directly tries File Search and Run
Code before text. Without a file tool, and under the classic policy,
every attachment keeps its classic route and limits unchanged. Explicit
destinations, `legacyFileUploadUX` and `defaultLLMDeliveryPath` still win.

Code access is independent of the primary reader: a compatible original
the model reads natively or as text is also advertised to Run Code at a
stable path and copied into the sandbox only when code first runs. The
model receives a per-turn inventory of how each attachment can be read
and which it cannot. Text is derived from the retained original only when
a reader needs it, under the deployment's text allowlist, and cached on
the record (`metadata.textDerivation`). A pending File Search file is
advertised and indexed before the first search; a failed preparation
fails the turn with the stable `file_search_preparation_failed` error.
Native size limits resolve in one place, a provider's capacity rejection
moves the file to the next reader instead of dropping it, and a resumed
turn charges checkpoint files by the resuming agent's decision.

The UI is unchanged except that the preview dialog no longer treats
Office Open XML types as XML text, which rendered a code-kept workbook as
raw ZIP bytes; such a file shows the existing unavailable state.

The e2e mock lane gains fake-model, code and RAG fixtures that record
what the model, the sandbox and the vector store received, and an
automatic-upload spec that proves the policy end to end.
The capability-gate invariant pins every read of the Run Code and File
Search capabilities per file. Upload processing gained one: the read
that decides whether a spreadsheet is left to Run Code, paired with the
Run Code role grant inside resolveUploadCodePossible. The count for
process.js moves from five to six with that pairing recorded.
@codegraph-librechat codegraph-librechat Bot added the 🗺️ Agent Runtime codegraph: the taxonomy area this belongs to (classifier, confidence ≥ 0.9) label Oct 15, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🗺️ Agent Runtime codegraph: the taxonomy area this belongs to (classifier, confidence ≥ 0.9)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant