You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Drag and drop, PR 2 of 2 (binary data files) for the admin agent chat. PR 1 (#12192) read text files in the browser; this PR reads Excel (.xlsx, .xlsm, .xlsb, .xls), OpenDocument (.ods), Parquet, Feather (v1 and v2), Arrow IPC (file and stream) and NumPy (.npy, .npz) files on the server, in the renderer's sandbox, and puts the text into the paste box. The user's .parse() then sends it through the ordinary paste route, unchanged (data/parse.py is untouched), with the file name and sheet as source (Bericht.xlsx · sheet "2024"), screened as in PR 1.
Converter (agents/anyplot/render/convert.py, standalone like the harness, copied to /opt/anyplot/convert.py). Format from the first bytes only, never the name.
Every zip is checked from its central directory first: at most 2,000 entries, 100 MB declared uncompressed, 250 times the archive's size, no encrypted member.
An OLE container with EncryptedPackage is refused with "remove the password". Pickle, joblib and HDF5 are never read.
Workbooks go through python-calamine (MIT): stored values only, so no formula, external link, macro or DDE runs. The sheet goes out as its raw cell grid via csv.writer: ISO dates, shortest numbers, TRUE/FALSE, empty for nothing. So the parser's table_block finds a header below row 1 and keeps a title as a note (tested end to end with parse_dataset).
Merged cells keep their value in the top-left cell only. A formula without a saved value is left empty and counted (xlsx and xlsm).
Sheets are read row by row and refused past 20,200 raw rows or 50 columns, whatever dimension the file declares. Hidden sheets are skipped and counted; very hidden ones are never named.
Parquet, Feather and Arrow go through pyarrow (26.0.0, at least 14.0.1 for CVE-2023-47248). The footer row and column counts are checked before any page is read, at most 20,001 rows are read, and every read is single-threaded.
Structs become dotted columns, dictionaries are decoded, and timestamps with a time zone are written in UTC. Map, binary and union columns are dropped and counted.
List columns follow feat(agents): read space-aligned tables and API-wrapped JSON #12191's list rule. A table with a list column goes out as JSON records, so the parser's own expansion does the work (one row per value, record_id, thinning). There is no second implementation.
NumPy loads with allow_pickle=False after the header's shape is checked against the bytes present. Only 2-D or structured arrays are read; each .npz member is a "sheet", and a pickled member is skipped or refused.
Caps: 1 MiB of text (the paste cap), 20,000 rows, 50 columns. A file over a cap is refused with its numbers, never cut.
Harness convert mode (harness.py --convert input.bin). As in data mode it reads the bytes, loads the converter, and preloads the readers (including a cp437 and deflate warm-up for zipfile) before it arms ConvertGuard. ConvertGuard is DataGuard with no exec at all. It allows one compile: NumPy's .npy header reader parsing its dict literal to an AST, matched by its exact caller chain. The harness writes out.csv, meta.json, and status.json with the refusal's exit code (10 + REFUSALS index), the measured value and the cap.
RendererPOST /convert (wire.ConvertRequest: base64, sheet, at most 15 s). It uses the same slot, memory floor, watchdog, kill path and idempotency key (job_id, convert). executor._prepare writes bytes. The sandbox environment adds ARROW_DEFAULT_MEMORY_POOL=system. Nothing of the file is logged.
AgentsPOST /v1/sessions/{sid}/dataset/convert:
Backends: ConvertJob and ConvertOutput in remote, local (mounts the converter), fake and sandbox (unsupported).
SerialRenderer.convert takes the toggle lane, behind every pipeline render. It runs with a 10 s timeout and a 20 s slot wait, then 503 capacity.
Limits: its own counter (5 a minute, 30 a day, AGENT_USER_CONVERSIONS_PER_*), checked after the daily budget and strikes. One conversion per user at a time (Runtime.converting).
Refusals answer 422 file_<reason> with measured and limit. A run the host stopped answers file_timeout or file_too_complex. A renderer without the route answers 503 convert_unavailable.
The data_convert attribution line carries kind, outcome, byte count and time only.
BFFPOST /debug/agent/sessions/{sid}/dataset/convert. The body is taken by hand, so the new router dependency require_body_within_cap refuses an oversized body by its Content-Lengthbefore it is read (413); a chunked conversion body gets 411. The BFF caps the file at 5 MB raw and passes only whole-number measured and limit through.
Page.fileText.ts hands the four convertible kinds to the server, up to 5 MB, and its accept list gains the extensions and MIME types. In DataPanel, a conversion fills the box like a text file. While a file is read, .open(), .parse() and the drop zone wait. The line names the sheet, the shape and the warning counts, and a refusal names its numbers. A sheet picker reads another sheet again: over the file's own untouched text it replaces at once, over an edited box it asks .replace().
Analytics.agent_data_file gains the server kinds (xlsx, ods, parquet, feather, arrow, npy, npz, ...) and the outcomes sheet and failed (docs/reference/plausible.md).
Docs. Design doc (request flow, renderer, guardrail row, /v1 table), api.md (route and error section), agents/README.md settings and the changelog fragment. The dev BFF mock answers the conversion.
Decided and documented (within the brief)
Conversion and paste count separately. The conversion counts on its own window and never as a paste. The paste that follows counts as one paste (settings.user_conversions_per_minute docstring).
Lane. The conversion takes the toggle lane, so a waiting pipeline render always goes first.
Auto-pick. Without a chosen sheet, .npz skips pickled members (refused only when nothing else is readable or the user picks one). A workbook reads the first visible sheet with a cell.
Sheet list. Only visible sheets with cells are offered (owner decision 2026-10-15); the first 50 are checked, the rest counted.
Security review (Fable with 60 crafted files through the real harness, Gemini by reading), all fixed with a test each:
Quadratic regex. The xlsx scan now uses one possessive tag tokenizer, which is linear. The review's 30 MB run of <c previously ran to the 10 s kill; the scan now takes 0.39 s, and the crafted workbook converts in under 1 s (both asserted).
Parquet row count. The pre-check adds up the row groups, and a footer total that disagrees is file_corrupt.
Declared zip sizes. Every zip member is inflated (zlib, bounded by its declared size + 1, at most 100 MB in all). A member whose size differs from what it declares is corrupt.
Bounding box. One linear pass measures an xlsx sheet's cell span before calamine loads it; past the caps it is refused with the count. A reader that dies without status.json (SIGABRT) is now file_too_complex.
Memory.MemoryError and ArrowMemoryError now map to too_complex instead of corrupt.
Small fixes:
npz and sheet names with controls, bidi characters or paths are never offered;
a broken .npy header (TokenError) is corrupt;
durations and intervals are dropped and named;
a busy renderer (503 busy or low_memory) answers capacity;
the jemalloc docstring is corrected;
ConvertGuard keeps none of the CODE_BUILDERS.
Found by the new in-image smoke: the UTF-16 codec of the OLE sniffer loads lazily, and the guard refused it, so encrypted workbooks came back blocked. It is now preloaded and tested under the real guard.
Owner decisions of 2026-10-15:
pyarrow is slimmed in the renderer Dockerfile. Removed: headers, Cython sources, tests, Flight, S3, Azure/GCS/HDFS, ORC, the Substrait Python module, Parquet encryption, the C++ test module and two duplicate libarrow_python copies. libarrow_substrait stays because lib links it. CI now converts every format inside the built image (tests/image/convert_smoke.py, new step "Convert every data format through the harness").
A formula-only sheet is refused as file_formulas_only: "contains only formulas without saved values; open it in Excel and save it once". The formulas are counted even when the grid is empty.
Empty sheets are hidden from the picker.
Precision is exact, per the owner rule:
timestamps keep every digit (nanoseconds) and their offset in their own zone; UTC with a warning only where the zone cannot be read;
decimals and int64/uint64 are exact;
float32 is written in its shortest text;
dropped columns are named on the page (dropped).
The parser (parse._as_iso_stamps) reads ISO timestamps with 1–9 fraction digits and Z/±HH/±HHMM/±HH:MM offsets as datetimes. Each cell is padded to the column's longest fraction, which keeps every value and lets pandas' read_csv(parse_dates=…) load the column as datetime64[ns, UTC+01:00]. A column with several offsets is written in UTC with a note. Cells with and without an offset in one column are text. prepare_data writes fractions and offsets back (isoformat()). The profile's min/max are the exact cells.
Conversion limits and the sheet picker wording stay.
Measurements
Renderer image size, from the CI image job:
with full pyarrow: 1,012,827,949 bytes;
slimmed: 944,079,490 bytes (−68.7 MB);
pyarrow itself: 167 MB → 99 MB.
No main run in the last 60 built the renderer image, so I have no without-pyarrow baseline; about 848 MB if pyarrow and calamine are subtracted.
The crafted inputs: the 30 MB tag run scans in 0.39 s locally, the zip-inflation check is linear, and a cell at A1048576 or XFD1 is refused before loading.
No binary fixture is committed. Workbooks, ODS files, zip bombs, OLE stubs, Parquet, Feather, Arrow and NumPy files are all built in memory (tests/unit/agents/runtime/convert_fixtures.py). New dependencies: python-calamine MIT, pyarrow Apache-2.0 (with its NOTICE in the wheel).
Test plan
ruff check, ruff format --check, mypy api core agents clean; uv lock --check passes (pyarrow is added to the mypy ignore_missing_imports list because it ships no types).
pytest tests/unit -n auto: 11,282 passed. The one failure is the known xdist flake test_pipeline_retry_gaps.py, which passes serially.
test_convert.py (49 tests), covering the brief's fuzz list:
zip bomb, entity expansion (never expanded), spoofed XFD1048576 dimension
formulas without cache, encrypted OOXML, .xls container sniff, ODS, multi-sheet with hidden and very hidden sheets, merged cells
header not in row 1 through parse_dataset, row, column and byte caps
hostile and garbage Parquet footers and Arrow streams, the footer row cap
Feather v2, Arrow stream, list columns through parse_dataset (6 rows)
.npy 2-D, structured, 1-D refused, object array refused, spoofed header shape, .npz with a pickled member
pins across converter, harness and wire; real-harness runs per format under the guard
Renderer: executor convert (argv, environment, real harness behind the fake launcher, SIGPIPE launcher, meta validation), service /convert (contract, invalid jobs not echoed, shared slot, replay and conflict, log without content), wire bounds, remote backend (round trip, end to end with the real harness, 404, busy).
Agents route: answer shape, nothing stored, refusals with numbers, timeout, memory and error mapping, convert_unavailable, bad bodies, own rate window that leaves the pastes alone, one conversion per user, busy slot, toggle lane behind a waiting render.
BFF: forwarding, body allowlist, 5 MB cap, Content-Length cap before reading, 411 for a chunked body, measured and limit filtering, CSRF header.
cd app && yarn lint && yarn fm:check && yarn type-check && yarn test && yarn build on Node 24 (1,108 tests): fileText convert hand-off and caps, convertAnswer, toBase64, toAgentApiError counts. DataPanel tests cover base64 hand-off and source, warnings line, sheet picker re-read, ask-before-replace after an edit, refusal with counts, waiting while a file is read, pickle never sent.
/verify-frontend against the BFF mock (3017/8017), checked at 1440 light and at 390 dark:
an .xlsx through .open() fills the box with the sheet, the warnings line and the picker;
choosing sheet "2025" reads it again;
a row-cap refusal shows its counts, wraps at 390 px, and the picker label is 13 px;
no horizontal overflow;
the console shows only the known font-CORS noise off port 3000 and the expected 422.
Not verifiable locally: the real Cloud Run sandbox, and the image build and size (no Docker daemon). .xls and .xlsb parsing has no generated fixture (no writer exists); only the sniff and refusal path is tested.
Follow-ups (not in this PR)
Arrow/Feather row count before decoding (Copilot round 2, thread left open; the review's item 5). IPC and Feather batches, and Feather v1's read_table, decode before rows are counted. A decompression bomb is bounded by the sandbox and now answers file_too_complex instead of file_corrupt, but not with the row-cap message. Fix: a small flatbuffer reader of the RecordBatch metadata.
Bounding-box pre-scan for ODS, XLS and XLSB. The linear span scan covers xlsx/xlsm only. ODS number-rows/columns-repeated and the binary formats still rely on the sandbox limits and the new file_too_complex mapping.
Mixed offsets in one column are written in UTC with a note, because pandas reads one offset per column. Keeping each offset would need an object column in the plot loader.
There are no new Plausible registrations: the new kind and outcome values use properties that already exist.
the rows of a table block keep their empty trailing fields;
sheet names travel whole;
repeated flattened names are renamed and counted;
UTC times are written in a form the parser reads.
Round 2 (3 comments): two applied in 9a7c44d (ConvertGuard keeps none of the CODE_BUILDERS; meta.json is written unescaped). One is left open as follow-up 1.
Security review: all applied in fe3276a (see above); per the coordinator, there is no new Copilot round.
ruff, format and mypy are clean; pytest tests/unit -n auto shows 11,324 passed, with only the known xdist flake. One CI run of the test job died at 79% with no failing test in its log; the rerun is green.
New tests:
TestSecurityReview and TestExactValues in test_convert.py;
harness runs under the guard for zone-aware timestamps and an encrypted workbook;
TestPreciseTimestamps in test_parse.py;
fractions and offsets through prepare_data;
the route's mapping of a dead reader to file_too_complex and of file_formulas_only;
the remote backend's busy → RenderBusy;
the frontend's dropped-columns line.
The CI renderer image converts all 14 cases inside the slimmed image.
Frontend: 1,109 tests pass, and lint, format check, type-check and build pass.
…mPy files
PR 2 of 2 of drag and drop: a binary data file goes as base64 to the new
POST .../dataset/convert route, which converts it in the renderer's sandbox
(harness.py --convert, agents/anyplot/render/convert.py, the guard armed
after the readers are loaded) and answers the text for the paste box; the
user's .parse() sends it through the ordinary paste route with the file
name and sheet as source. Sheet picker, own conversion limits, zip and
footer pre-checks, list columns as JSON records for the parser's list rule.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdMSqLR5ww4ji74EUSmk9M
…ions
The CI type-check installs no `convert` extra, so python_calamine joins the
ignore_missing_imports list next to pyarrow. The local Docker backend's
conversion and the fake and sandbox backends' get tests (patch coverage).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdMSqLR5ww4ji74EUSmk9M
Adds sandboxed binary-file conversion to the agent chat, extending the existing text-file import flow while keeping parsing and screening on the ordinary paste route.
Changes:
Adds conversion APIs, renderer execution, backend support and separate usage limits.
Connects binary-file imports and sheet selection to the data box.
Adds conversion tests, dependencies, documentation and analytics updates.
… UTC times
Copilot review of #12194:
- A table block's rows keep their empty trailing fields, so `C,` stays a
data row instead of a note (`pad_table_rows`); tested through the parser.
- Sheet names travel whole (up to the wire's 255 characters), because the
page sends a listed name back as the choice; longer ones are not listed.
- Time-zone timestamps are written in UTC without an offset, which the
parser's ISO pattern reads, and counted (`utc_timestamps`).
- A repeated flattened column name gets a `_2` suffix and is counted
(`renamed_columns`), so a JSON record never drops a column.
- The BFF needs a declared Content-Length for every conversion (411).
- Harness convert mode is also run in process with an inert guard.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdMSqLR5ww4ji74EUSmk9M
The selected sheet can be absent from sheets: the converter searches every visible sheet but lists only the first ten. With ten empty sheets followed by a populated Data sheet, this select has value Data without a matching option, so the displayed choice does not identify the data in the box. Include an option for the current sheet when it falls outside the listed choices, and cover this auto-selection case in a panel test.
Second Copilot round on #12194:
- ConvertGuard no longer inherits data mode's CODE_BUILDERS exemptions:
NumPy's header compile is the only code event it lets through.
- meta.json is written without ASCII escapes, so ten 255-character CJK
sheet names stay under the host's 16 KiB cap (regression test).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdMSqLR5ww4ji74EUSmk9M
Security review of #12194 (Fable with crafted files, Gemini by reading):
- The xlsx scans are linear: a possessive tag tokenizer replaces the lazy
cross-cell pattern (a 30 MB run of `<c ` held the slot to the 10 s kill).
- Parquet adds up its row groups and refuses a footer that disagrees.
- Every zip member is inflated, bounded by its declared size, to prove it.
- An xlsx sheet's cell span is measured before calamine allocates its box;
a reader that dies without status.json is file_too_complex.
- MemoryError and ArrowMemoryError are too_complex, not corrupt.
- Sheet and npz names with controls or paths are never offered; a broken
npy header is corrupt; durations and intervals are dropped and named;
a busy renderer is capacity; the guard keeps no CODE_BUILDERS.
Owner decisions of 2026-10-15:
- pyarrow is slimmed in the renderer image (163 MB to 99 MB) and every
format is converted inside the built image in CI.
- A sheet of only formulas without saved values is refused with a hint.
- Empty sheets are not offered in the picker.
- Data keeps its precision: timestamps keep every digit and their offset,
decimals and 64-bit integers are exact, dropped columns are named; the
parser reads ISO timestamps with fractions and offsets as datetimes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdMSqLR5ww4ji74EUSmk9M
Merge #12194 (ISO timestamps with fractions and offsets in parse.py):
its _cell_text (value.isoformat()) replaces moment_text; both test
classes kept.
Owner decisions of 2026-10-15:
- one warning counts numbers whose spelling went (trailing zeros,
exponent, plus sign), naming their columns
- set_type datetime reads different UTC offsets in UTC with a change
line naming them, as the parser does; naive beside zoned is refused
- the preview keeps whitespace; the data panel draws line breaks as ↵
and space runs as ·, muted and named for screen readers
- profile mean/median at six digits (model only) and integers past 2^53
in the browser kept and documented
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdMSqLR5ww4ji74EUSmk9M
## Summary
This PR audits how faithfully the agent chat keeps pasted data and fixes
every silent precision loss it found. It follows the owner rule of
2026-10-15: what the user pastes is what the data holds, or a warning or
change line says what changed and how often. Silent precision loss is
treated like silent corruption.
### Audit
| Path | What can be lost | Before | After |
|---|---|---|---|
| `code/loader.py`: run-form `pd.read_csv` (exported `plot.py`, sandbox
render) | float digits | No `float_precision`. On pandas 3.0, about 23 %
of realistic 17-digit floats were read 1 ulp off, so the plot did not
draw the values in `data.csv`. | `float_precision="round_trip"` whenever
a number column exists. Test: 2,000 random floats of every magnitude
plus extremes read back bit-exactly. |
| `parse.py` `_as_numbers`: CSV number cells | digits past float64
(`0.1234567890123456789`, `9007199254740993` beside decimals,
subnormals, `1e-400`) | Silently rounded. | Stored as the nearest float
(repr), with a warning that counts the cells: "N values with more
significant digits than a number holds (15 to 17) are stored rounded". |
| `parse.py` `_decode_literal`: JSON and Python-literal floats | digits
| `json.loads` and `literal_eval` rounded them before typing, with no
warning. | The written text is kept (`_SourceFloat`), so the same count
applies. JSON `1e400` is now text instead of vanishing as missing. |
| `parse.py` integers past int64 | last digits of IDs and counts |
Became float (`1.2345678901234568e+22`). | The column stays text with a
warning. `set_type number` can still convert it, and its change line
counts the rounding. |
| `parse.py` profile `min`/`max` of integer columns | digits past 2^53 |
float | Exact `int` (`ColumnProfile.min/max: int \| float \| Cell`). |
| `parse.py` trailing zeros, `+`, exponent, `-0` | spelling | `1.50`
becomes `1.5`, `1e3` becomes `1000.0`, `-0` becomes `0` | Unchanged by
design: the value is identical (shortest round-trip form). See the
findings below. |
| `parse.py` thousands and decimal-comma normalization, `%` and currency
| value | Already warned (notes and unit-moved warnings). | Unchanged,
now covered by an exact-digits test. |
| `parse.py` text cells: surrounding whitespace | characters | Silently
stripped. | In text columns, one warning counts the cells. Space-aligned
tables (layout padding) and number or date columns get no warning. |
| `parse.py` text cells: `None`, `-`, `null`, … | the whole cell (a
possible category) | Silently became missing. | In text columns, one
warning counts the cells and names the tokens. Number and date columns
are unchanged (the usual missing markers there). |
| `parse.py` NFC | code points | Silent. | One warning counts the
changed lines. |
| `parse.py` zero-width and bidi characters, header canonicalization,
200-character cell cap | | Already warned or refused. | Unchanged. |
| `parse.py` thinning of list fields over 20,000 rows | rows | Already
warned with counts. | Unchanged. |
| `prepare.py` `set_type number` | whole numbers past 2^53, many-digit
decimals | Read through float (`9007199254740993` became `…992`),
silently. | Whole numbers are read exactly through `Decimal` (Int64).
The change line counts rounded cells. |
| `prepare.py` `aggregate sum` of Int64 | the whole value | pandas
wrapped around silently (`2 × 9.2e18` gave `-1616`). | Summed in Python
ints. Past int64 the result is float, and the change line counts the
rounded values. |
| `prepare.py` `derive cumsum` of Int64 | digits past 2^53 | Computed in
float64, silently. | Computed in Python ints and stays whole. Past int64
the change line counts the rounded values. |
| `prepare.py` `derive sum` and `difference` past int64 | digits |
Silent fallback to float. | The change line counts the rounded values. |
| `prepare.py` `pivot_longer` of Int64 beside float64 | digits past 2^53
| Silent cast. | The change line counts the rounded whole numbers. |
| `prepare.py` derive `ratio`, `share`, `percent` | digits | Full float,
written with repr. | Unchanged, now covered by a test. |
| `prepare.py` `_cell_text`: date-times | sub-seconds |
`timespec="seconds"` dropped them silently. | `moment_text`:
microseconds or nanoseconds when present. The offset is kept. |
| `prepare.py` `set_type datetime` without a format | month and day
order | `2024-06-01T13:00:00Z` was read as 6 January (`dayfirst=True`).
| Year-first columns are read year, month, day. |
| `prepare.py` `set_type datetime` with mixed UTC offsets | | Uncaught
`ValueError`, so the tool crashed. | `type_mismatch` refusal. The times
are not converted. |
| `prepare.py` `_read` and `_write`, `render/harness.py` `frame_csv` | |
`_read` already used `round_trip`. `to_csv` writes repr (verified
bit-exact on 20,000 random doubles). | Unchanged. |
| `parse.py` dates, `_iso`, loader `parse_dates` | seconds and range |
Seconds are kept. Years 1 to 9999 read as `M8[us]` on pandas 3. |
Unchanged. Fractional seconds and offsets at parse time belong to the
sibling PR (`feat/agent-file-drop-binary`). |
| Page preview (`DataPanel` `PreviewTable`) | display | Shows the
canonical cells verbatim, with no number formatting. Cells over 40
characters are cut with `…`, and whitespace runs are collapsed for
display. | Unchanged. See the findings below. |
### Owner decisions (2026-10-15), applied
1. **Trailing zeros and spelling.** The value is enough (`1.50` becomes
`1.5`), but one warning now counts the numbers whose trailing zeros,
exponent or plus sign went, and names their columns. Spelling the
canonical form only adds (`2` as `2.0`, `.5` as `0.5`, `1e-7` as
`1e-07`) is not counted.
2. **Different UTC offsets** are converted to UTC with a change line
that names the offsets, consistent with the parser since #12194. This
now also applies to `set_type datetime`. Times with and without an
offset in one column are refused.
3. **Preview.** The server keeps each preview cell's whitespace
(`parse.preview_cell`). The data panel draws a line break as `↵` and
each space of a run as `·`, muted and with screen-reader text. The
model-facing sample and top values stay one line.
4. **Profile mean and median at six significant digits** are kept, for
the model only; this is documented.
5. **Integers past 2^53 in the browser** are kept as is, documented.
This only affects the binding hint.
6. **The CSV-delimiter space exemption** stays: there is no warning for
`1, alpha`.
### Merge with #12194
origin/main is merged in. #12194's `_cell_text` (`value.isoformat()`,
which writes every fraction digit and the offset) replaces this PR's
`moment_text`, and both test classes are kept. The stress case
`duplicate-headers` now expects 4 warnings, including the spelling line;
it was regenerated with `make_stress`. The parse-only fixture test
ignores the spelling line.
### Frontend
The change is `app/src/sections/agent-chat/previewCell.ts` plus its use
in `DataPanel`, with Vitest coverage in `previewCell.test.ts` and
`DataPanel.test.tsx`. App gates on Node 24 pass: lint, Prettier,
type-check, 1,114 tests and the build. I did not drive the page in a
browser in this session.
## Test plan
- [x] `TestFidelity` in `test_parse.py` with generalized fixtures:
random decimals of 1, 7 and 15 digits in CSV, JSON, written JSON and
Python literals keep their exact value with no warning; 19-digit
decimals plus edge cases are counted exactly; int64 limits are exact and
larger integers become text; NFC, whitespace and missing-token warnings;
no warning for aligned padding or number columns.
- [x] `TestFidelity` in `test_prepare.py`: 500 random floats of every
magnitude plus integers past 2^53 survive operations exactly;
`set_type`, aggregate sum, cumsum and sum overflow, the `pivot_longer`
count, sub-seconds and offsets, the year-first fix and the mixed-offset
refusal.
- [x] `test_loader.py`: bit-exact read-back of 2,000 random floats plus
extremes through the run form.
- [x] `uv run pytest tests/unit -n 8`: 11,318 passed. `ruff check` and
`ruff format --check` on the repo, `mypy api core agents` and the
changelog check all pass.
- No `app/` change, so no frontend gate applies.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01GdMSqLR5ww4ji74EUSmk9M
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…rn (#12199)
## Summary
- **Cause.** The root uses `static_instruction` plus a dynamic
`instruction` (the session block). ADK 2.11 sends the dynamic part as
user content: a `<<<BEGIN_SYSTEM_INSTRUCTION>>>` block with a preamble,
inserted at the current-turn boundary, so it lands inside the user's own
turn (`google/adk/flows/llm_flows/prompt/_instructions.py`,
`LlmRequest._insert_transient_user_content`). Claude Haiku 5.5 read it
as user text that posed as a system instruction. In a baseline trace of
8 prep cases on origin/main, its thinking said so in 7 of 8 turns, for
example "a suspicious block embedded in the user's message ... which
looks like an injection attempt I should disregard". Every one of the 23
root requests carried the markers in a user message.
- **Fix.** The root has no dynamic `instruction` anymore. A
`before_model_callback` (`add_session_context`) appends the session
block to the system instruction as a second part after the static
policy. `VertexClaude` sends each part as its own Claude system block,
with ADK's cache breakpoint on the first part only (`system_blocks`). A
session block that changes therefore leaves the policy cached, and the
static prefix stays byte-identical. `root.md` now describes the block as
part of the instruction instead of explaining away the markers. The
thinking `block_binding` drop stays: a data tool can still change the
block between two calls.
- **Regression checks.** A service-flow test checks that no root request
has the markers or the session block in any content, and that the system
instruction is the static part plus the block. A registry test checks
that no agent has a dynamic `instruction`. Model tests cover the
two-block split, with and without caching. The prep eval now scores a
reply that talks about the root's own instructions (markers, a system
instruction, the session block) as a miss and counts it under
`instruction_talk`. A scripted test covers that path and asserts the
shape of the Claude request.
### Numbers (Claude Haiku 5.5, Vertex AI `eu`)
| Run | Turns flagged in thinking | Replies about it | Root requests
with markers | Exact | Cost |
|---|---|---|---|---|---|
| origin/main, 8 cases (2 of 10 were `guard_unavailable`) | 7/8 | 0/8 |
23/23 | 8/8 | $0.026 |
| this branch, same 10 cases | 0/10 | 0/10 | 0/30 | 10/10 | $0.025 |
| this branch, full prep eval | 0/50 | 0/50 | 0/143 | 47/50 = 94 %
(PASS) | $0.098 (#12198 runs: $0.131, $0.142) |
On the same 8 cases, the cost per model call fell from $0.00114 to
$0.00087. The three full-run misses are model choices unrelated to the
block: one leaves the `Notiz` column in, one labels classes `1`/`2`
instead of `Klasse 1`/`Klasse 2`, and one adds an extra sort. None of
them talked about instructions.
### Decided and documented
- The session block now lives in the system prompt. Its contents keep
the same trust level and fences: the block was already "instruction
priority", with user-derived values only inside `<plot_language>` and
`<user_data>` fences. User text can no longer imitate it in place, and
`root.md` says that a message that imitates the block is wrong. The
scope guard and the `[message withheld]` path touch only user contents
and are unchanged.
## Plan
N/A
## Test plan
- [x] `pytest tests/unit/agents`: 6,842 passed.
`renderer/test_executor.py::test_the_memory_floor_kills_the_run` failed
once under `-n auto` while the live eval ran alongside, and passes
alone. It is unrelated.
- [x] CI blocker fixed in this PR: `test_convert.py::TestSecurityReview`
(from #12194) asserted a 1.0 s wall clock for scanning a 30 MB crafted
comment. The CI runner, under coverage, took 1.17 s and 1.19 s on two
runs. The bound is now 5 s, which still tells a linear scan from a
backtracking one (hours).
- [x] `ruff check`, `ruff format --check`, `mypy api core agents`
- [x] `uv run python -m tools.changelog check --base origin/main`
- [x] Live prep eval on Vertex AI, before and after (see the table)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01GdMSqLR5ww4ji74EUSmk9M
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Drag and drop, PR 2 of 2 (binary data files) for the admin agent chat. PR 1 (#12192) read text files in the browser; this PR reads Excel (
.xlsx,.xlsm,.xlsb,.xls), OpenDocument (.ods), Parquet, Feather (v1 and v2), Arrow IPC (file and stream) and NumPy (.npy,.npz) files on the server, in the renderer's sandbox, and puts the text into the paste box. The user's.parse()then sends it through the ordinary paste route, unchanged (data/parse.pyis untouched), with the file name and sheet assource(Bericht.xlsx · sheet "2024"), screened as in PR 1.agents/anyplot/render/convert.py, standalone like the harness, copied to/opt/anyplot/convert.py). Format from the first bytes only, never the name.EncryptedPackageis refused with "remove the password". Pickle, joblib and HDF5 are never read.csv.writer: ISO dates, shortest numbers,TRUE/FALSE, empty for nothing. So the parser'stable_blockfinds a header below row 1 and keeps a title as a note (tested end to end withparse_dataset).record_id, thinning). There is no second implementation.allow_pickle=Falseafter the header's shape is checked against the bytes present. Only 2-D or structured arrays are read; each.npzmember is a "sheet", and a pickled member is skipped or refused.harness.py --convert input.bin). As in data mode it reads the bytes, loads the converter, and preloads the readers (including a cp437 and deflate warm-up forzipfile) before it armsConvertGuard.ConvertGuardisDataGuardwith noexecat all. It allows onecompile: NumPy's.npyheader reader parsing its dict literal to an AST, matched by its exact caller chain. The harness writesout.csv,meta.json, andstatus.jsonwith the refusal's exit code (10 +REFUSALSindex), the measured value and the cap.POST /convert(wire.ConvertRequest: base64, sheet, at most 15 s). It uses the same slot, memory floor, watchdog, kill path and idempotency key (job_id,convert).executor._preparewrites bytes. The sandbox environment addsARROW_DEFAULT_MEMORY_POOL=system. Nothing of the file is logged.POST /v1/sessions/{sid}/dataset/convert:ConvertJobandConvertOutputin remote, local (mounts the converter), fake and sandbox (unsupported).SerialRenderer.converttakes the toggle lane, behind every pipeline render. It runs with a 10 s timeout and a 20 s slot wait, then503 capacity.AGENT_USER_CONVERSIONS_PER_*), checked after the daily budget and strikes. One conversion per user at a time (Runtime.converting).422 file_<reason>withmeasuredandlimit. A run the host stopped answersfile_timeoutorfile_too_complex. A renderer without the route answers503 convert_unavailable.data_convertattribution line carries kind, outcome, byte count and time only.POST /debug/agent/sessions/{sid}/dataset/convert. The body is taken by hand, so the new router dependencyrequire_body_within_caprefuses an oversized body by itsContent-Lengthbefore it is read (413); a chunked conversion body gets411. The BFF caps the file at 5 MB raw and passes only whole-numbermeasuredandlimitthrough.fileText.tshands the four convertible kinds to the server, up to 5 MB, and itsacceptlist gains the extensions and MIME types. InDataPanel, a conversion fills the box like a text file. While a file is read,.open(),.parse()and the drop zone wait. The line names the sheet, the shape and the warning counts, and a refusal names its numbers. A sheet picker reads another sheet again: over the file's own untouched text it replaces at once, over an edited box it asks.replace().agent_data_filegains the server kinds (xlsx,ods,parquet,feather,arrow,npy,npz, ...) and the outcomessheetandfailed(docs/reference/plausible.md)./v1table),api.md(route and error section),agents/README.mdsettings and the changelog fragment. The dev BFF mock answers the conversion.Decided and documented (within the brief)
settings.user_conversions_per_minutedocstring)..npzskips pickled members (refused only when nothing else is readable or the user picks one). A workbook reads the first visible sheet with a cell.Security review and owner decisions (fe3276a)
Security review (Fable with 60 crafted files through the real harness, Gemini by reading), all fixed with a test each:
<cpreviously ran to the 10 s kill; the scan now takes 0.39 s, and the crafted workbook converts in under 1 s (both asserted).file_corrupt.corrupt.status.json(SIGABRT) is nowfile_too_complex.MemoryErrorandArrowMemoryErrornow map totoo_complexinstead ofcorrupt..npyheader (TokenError) iscorrupt;503 busyorlow_memory) answerscapacity;ConvertGuardkeeps none of theCODE_BUILDERS.blocked. It is now preloaded and tested under the real guard.Owner decisions of 2026-10-15:
libarrow_pythoncopies.libarrow_substraitstays becauseliblinks it. CI now converts every format inside the built image (tests/image/convert_smoke.py, new step "Convert every data format through the harness").file_formulas_only: "contains only formulas without saved values; open it in Excel and save it once". The formulas are counted even when the grid is empty.dropped).parse._as_iso_stamps) reads ISO timestamps with 1–9 fraction digits andZ/±HH/±HHMM/±HH:MMoffsets as datetimes. Each cell is padded to the column's longest fraction, which keeps every value and lets pandas'read_csv(parse_dates=…)load the column asdatetime64[ns, UTC+01:00]. A column with several offsets is written in UTC with a note. Cells with and without an offset in one column are text.prepare_datawrites fractions and offsets back (isoformat()). The profile's min/max are the exact cells.Measurements
tests/unit/agents/runtime/convert_fixtures.py). New dependencies: python-calamine MIT, pyarrow Apache-2.0 (with its NOTICE in the wheel).Test plan
ruff check,ruff format --check,mypy api core agentsclean;uv lock --checkpasses (pyarrow is added to the mypyignore_missing_importslist because it ships no types).pytest tests/unit -n auto: 11,282 passed. The one failure is the known xdist flaketest_pipeline_retry_gaps.py, which passes serially.test_convert.py(49 tests), covering the brief's fuzz list:XFD1048576dimension.xlscontainer sniff, ODS, multi-sheet with hidden and very hidden sheets, merged cellsparse_dataset, row, column and byte capsparse_dataset(6 rows).npy2-D, structured, 1-D refused, object array refused, spoofed header shape,.npzwith a pickled member/convert(contract, invalid jobs not echoed, shared slot, replay and conflict, log without content), wire bounds, remote backend (round trip, end to end with the real harness, 404, busy).convert_unavailable, bad bodies, own rate window that leaves the pastes alone, one conversion per user, busy slot, toggle lane behind a waiting render.Content-Lengthcap before reading,411for a chunked body, measured and limit filtering, CSRF header.cd app && yarn lint && yarn fm:check && yarn type-check && yarn test && yarn buildon Node 24 (1,108 tests):fileTextconvert hand-off and caps,convertAnswer,toBase64,toAgentApiErrorcounts. DataPanel tests cover base64 hand-off and source, warnings line, sheet picker re-read, ask-before-replace after an edit, refusal with counts, waiting while a file is read, pickle never sent./verify-frontendagainst the BFF mock (3017/8017), checked at 1440 light and at 390 dark:.xlsxthrough.open()fills the box with the sheet, the warnings line and the picker;.xlsand.xlsbparsing has no generated fixture (no writer exists); only the sniff and refusal path is tested.Follow-ups (not in this PR)
read_table, decode before rows are counted. A decompression bomb is bounded by the sandbox and now answersfile_too_complexinstead offile_corrupt, but not with the row-cap message. Fix: a small flatbuffer reader of the RecordBatch metadata.number-rows/columns-repeatedand the binary formats still rely on the sandbox limits and the newfile_too_complexmapping.There are no new Plausible registrations: the new
kindandoutcomevalues use properties that already exist.Review rounds
Content-Lengthis411;ConvertGuardkeeps none of theCODE_BUILDERS;meta.jsonis written unescaped). One is left open as follow-up 1.Test plan update (fe3276a)
pytest tests/unit -n autoshows 11,324 passed, with only the known xdist flake. One CI run of the test job died at 79% with no failing test in its log; the rerun is green.TestSecurityReviewandTestExactValuesintest_convert.py;TestPreciseTimestampsintest_parse.py;prepare_data;file_too_complexand offile_formulas_only;RenderBusy;🤖 Generated with Claude Code
https://claude.ai/code/session_01GdMSqLR5ww4ji74EUSmk9M