Skip to content

A stored .docx contributes nothing — extractFileText handles text and PDF only, so read_file and RAG see an empty document (and .doc is a different, harder problem) #763

Description

@serge-ivo

The problem

A .docx that reaches the file store is invisible to the agent. read_file refuses it, search_knowledge cannot retrieve from it, and the console shows a file with no content. The agent can say "there is a file called X" and nothing else about it.

That is the first wall in #756: the user's emailed club form arrives, gmail_download_attachment stores it (once #755 is fixed), and the agent still cannot tell the user a single thing the form asks for.

Priority — P2: correctness: the platform accepts and stores the file, then reports it as unreadable. Nothing is silently wrong, but a stored document contributing nothing to RAG is a real gap in a store that advertises document-grounded chat.

Where it is — VERIFIED

workers/api/src/agent-storage-utils.ts:133-149 is the whole extraction dispatch:

		if (isTextMimeType(mimeType) || /\.(txt|md|csv|json|html?|xml|js|ts|css)$/i.test(name)) {
			const text = new TextDecoder("utf-8").decode(bytes).trim();
			return text ? { text, status: "extracted" } : { text: "", status: "none" };
		}
		if (mimeType === "application/pdf" || name.endsWith(".pdf")) {
			const text = await extractPdfText(bytes);
			return text ? { text, status: "extracted" } : { text: "", status: "unsupported" };
		}
		return { text: "", status: "unsupported" };

Two formats, then a catch-all. A .docx takes the last line.

Downstream, read_file then fails honestly — workers/api/src/lib/storage-tools.ts:365-369:

				const file = await engine.fileGet(id);
				if (!file) return fail(call.name, `File not found: ${id}`);
				if (!file.meta.mimeType.startsWith("text/") && !/json|xml|csv|html/.test(file.meta.mimeType)) {
					return fail(call.name, `${file.meta.name} is ${file.meta.mimeType} and has no extracted text — its content is not readable as text`);
				}

The honesty is right and should stay; the gap is that there is nothing for it to read.

Absence checked: grep -rn "docx\|msword\|officedocument\|DecompressionStream" workers/api/src packages/sdk/src packages/browser-runner/src | grep -v '\.test\.' returns six hits — two are the MIME-type lookup table (storage-tools.ts:815-816), two are DecompressionStream used for PDF FlateDecode (agent-storage-utils.ts:200) and repo-tarball gzip (repo-ingest.ts:205), one is a comment in resume-parse.ts:52 ("Claude reads PDFs natively; skip other formats (docx/txt) for now — but SAY so"), and two are accept=".pdf,.txt,.doc,.docx" in browser-runner test fixtures. No ZIP reader exists anywhere in the treegrep -rn "deflate-raw\|central directory\|centralDirectory" workers/api/src packages/*/src returns nothing.

.docx and .doc are two different problems — do not conflate them

This is the distinction #756 collapsed, and it decides how much of this ticket is even possible.

.docx .doc
Container ZIP (deflate members) OLE2 / CFB compound binary
Text location word/document.xml — XML with <w:t> runs FAT-chained sectors, format-version-dependent, some parts compressed with a bespoke scheme
Tractable in a Worker? Yes Realistically no

The primitives for .docx already exist in this runtime: DecompressionStream is used twice today (agent-storage-utils.ts:200, repo-ingest.ts:205), and repo-ingest.ts:245 (readTar, "Minimal ustar/GNU tar reader") is the direct precedent for a hand-rolled archive walker in this codebase. A ZIP central-directory reader plus one DecompressionStream("deflate-raw") per member gets word/document.xml; stripping <w:t> runs and inserting a newline per <w:p> gets readable text.

.doc has no comparable path and no dependency in this tree can be added for it that runs on workerd. The user's actual attachment in #756 is the .doc. Scope this ticket to .docx and let #756's parent carry the decision about .doc.

What to do — cheapest first

  1. Say what it is, instead of nothing. In extractFileText, return status:"unsupported" with an error naming the format — a .doc/.docx should produce "this is a Word document; the platform cannot read it yet", not silence. The ExtractedFileText.error field already exists (agent-storage-utils.ts:127-131). Ships in isolation and is honest either way.
  2. .docx text extraction. ZIP member reader → word/document.xml → text. Keep it pure and next to extractPdfText, with the same size cap discipline. Once it returns text, read_file, search_knowledge and the Index panel all light up with no further change, because they all read fileGetText.
  3. Backfill is not automatic. Extraction happens at upload; existing stored .docx files stay empty. Either say so, or re-extract on first read_file miss. State which — this is a real decision, not an implementation detail.

Alternatives considered and rejected

  • Add mammoth / docx from npm. Rejected without measurement first: both assume Node buffers and streams. If a maintainer can show one bundling and running on workerd inside the Worker's size budget, that beats hand-rolling — but the tree's own precedent (readTar, the hand-written PDF FlateDecode path) is a 100-line reader, and unpdf was chosen for PDFs precisely because it is workerd-safe.
  • Convert via an external service. Rejected here: a per-file outbound call for a read is disproportionate, and it puts the owner's documents through a third party for a capability that is ~100 lines. It stays on the table for .doc, where it may be the only option — that belongs to Epic: the emailed-form workflow stops at step 2 — download is broken (#755), Word is unreadable (#763), and only the byte-upload parameter is genuinely missing (#762) #756.
  • Send the bytes to Claude and let it read them. Rejected: resume-parse.ts:65 does this for PDFs because Claude accepts a PDF document block natively. It does not accept .docx, so this is base64 of a ZIP in a prompt.

Acceptance criteria

  • A fixture .docx with three paragraphs, uploaded through fileUpload, yields extracted text containing all three, in order, with paragraph breaks preserved.
  • A .docx containing a table yields the cell text (order may be row-major; assert the strings are present, not the layout).
  • A .docx whose ZIP is truncated or whose word/document.xml is missing returns status:"failed" with an error, and does not throw out of fileUpload.
  • A .doc returns status:"unsupported" with an error string that names the format and says it is not supported — asserted on the string, so the honesty is pinned.
  • After extraction, read_file on the .docx returns the text window (not the has no extracted text refusal), and search_knowledge retrieves a phrase from it.
  • The extraction is bounded: a .docx over the cap is refused before decompression, not after.

Regression risk

  • Zip-bomb shape. A small .docx can inflate enormously. The cap must apply to the decompressed size as the stream is read, not to the stored size — readTar's caller pattern in repo-ingest.ts (per-file and total caps) is the model.
  • extractText runs inside fileUpload, on the request path. A slow or looping reader turns every upload slow. Bound iterations over ZIP members explicitly.
  • Vectorisation. A newly-extractable format starts landing in Vectorize; check that the chunking path treats it exactly like PDF text and that the Index panel's per-source counts stay coherent.

Parent: #756. Related: #762 (writing bytes back), #755 (getting the file in).

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2: correctnessReal defect, no live harm today — inert fields, miscounts, missing guardsbackendBackend / Worker / API workenhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions