Skip to content

Design Decisions

Daniel Boles edited this page Nov 6, 2026 · 1 revision

Design Decisions

Five decisions define this server. The sources are the code, its docstrings, and the README's design notes. Where a reason below is an inference rather than a recorded statement, the text says so.

1. One reading: accepted text

Problem. A document with tracked changes contains at least two texts, and an agent needs to know which one it is reading.

Options. Return everything with markup and let the agent interpret it; return the original text; offer a mode switch per call; or return one defined reading.

Choice. Every tool reads accepted text: insertions count, and w:del and w:moveFrom do not. A paragraph with a tracked change carries has_tracked_change: true, so the agent still knows a revision exists at that location.

Trade-off. The agent cannot see what the editor removed, which rules out a whole class of review tasks, such as judging whether a deletion was wise. One reading is simple to reason about, however, and a mode switch would let a mistaken call return deleted text as though it were the document.

What would change it. A real review workflow that needs the deleted text would justify a separate tool with its own name, so that the two readings could never be confused.

2. Parse the tree; do not match patterns in the XML

Problem. Deleted text can sit at any depth, moved text uses ordinary w:t elements, and a sentence can cross several runs.

Options. Regular expressions over the XML, or a walk of the parsed tree.

Choice. _collect_accepted() walks the tree with lxml and skips excluded elements wherever they appear. The tests cover the cases a pattern would miss: test_a_deletion_nested_inside_an_insertion_is_excluded, test_a_w_t_inside_a_deletion_is_excluded_too, and test_moved_away_text_is_excluded_and_moved_in_text_is_kept. The last case matters most, because moved-away text is not marked with w:delText, so a reader that looks only for that element would report it as live.

Trade-off. The cost is one compiled dependency, lxml, in a package that could otherwise use the standard library. That cost is small, since the parser also supplies the safety settings described in decision 4.

What would change it. Very little. The alternative is what produced the problem.

3. A paragraph's address is its place in the document

Problem. An agent reads in pages and searches across the whole file, so it needs one way to refer to the same paragraph in both.

Options. Row numbers within each result page, generated ids, or a position in the document.

Choice. para_index is the position in the list of body paragraphs. read_accepted_text filters short paragraphs by default, with min_length=10, yet the indexes of the rows it keeps do not change, as test_the_index_is_the_document_position confirms.

Trade-off. The index holds only for one version of the file. Any edit to the document can move every address after it, and the server has no way to detect that between calls.

What would change it. Long-lived references across edits would require a hash of the paragraph text or a stored id, neither of which is in scope for a read-only server.

4. Treat every file as untrusted

Problem. A DOCX can arrive from anywhere, and an XML part can carry entity bombs, external references, or a small zip that expands to a very large part.

Options. Trust the caller; validate the file beforehand; or configure the parser so that those attacks have nothing to act on.

Choice. _parser() sets resolve_entities=False, load_dtd=False, no_network=True, and huge_tree=False. _read_part() reads the declared size from the zip directory and refuses any part over 256 MB before decompressing it. The tests are test_an_external_entity_is_not_resolved, test_entity_expansion_does_not_blow_up, and test_an_oversized_part_is_refused_before_it_is_read.

Trade-off. The limit is generous at 256 MB per part, so a crafted file below it can still cost that much memory before any text appears. This defense also stops at parsing: find_docx_term runs the caller's regular expression with no time limit, so a pathological pattern can still stall the server.

What would change it. Exposure to untrusted callers, as opposed to untrusted files, would make a regex time limit the first requirement.

5. Could not read is not nothing found

Problem. An agent that receives an empty list cannot tell a clean document from a missing one, and the SDK withholds the text of unexpected exceptions from the client.

Options. Return empty results on failure; let the exception pass through; or convert expected failures into readable tool errors.

Choice. _reported_errors() in server.py turns OSError, ValueError, BadZipFile, re.error, and XMLSyntaxError into a ToolError that carries the type and the message. The library raises those same exceptions. Three tests cover a missing file, a damaged file, and a bad pattern, each named as an error the agent can read.

Trade-off. The error text can repeat the file path to the client, which is acceptable for a local stdio server and would need review behind a network boundary.

What would change it. A network transport would call for a narrower error message.