Repository navigation
fix(agent): read the drawn texts of every library for the reply - #12503
Conversation
The user test of 2026-11-01 found Chart.js, bokeh and pygal replies saying the plot's title and labels could not be read back, and a Makie reply offering a title the plot already had. Only matplotlib, seaborn and plotnine reported their drawn texts; for the other twelve libraries the root got `shows.texts: null` and narrated it. - harness.py: pygal (cairosvg's SVG), altair (vl-convert's scenegraph) and lets-plot (its bridge's SVG) write a probe of their drawn texts by role (`install_text_probes`), under the matplotlib probe's caps. - render.mjs: in sandbox mode the browser harness writes the texts the page draws after the screenshot (`writeTextProbe`): SVG and HTML texts by the library's classes, ECharts' zrender display list, and Chart.js's and bokeh's chart models. A failing or slow probe is left out; it never fails a render. - The requested-text check stays with the reviewer for these libraries (`review_texts`), and G12 reads matplotlib's probe only. - The root never sees a null `texts`: `fence_pipeline_notes` drops it and root.md no longer mentions unread texts. - The eval report counts shipped runs without drawn texts per library (`untexted`), from a content-free `texts` field on `pipeline_result`. ggplot2 and Makie follow in a separate PR (renderer image changes). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
Gemini Flash's pre-check: a cycle in zrender's parent chain would spin until the probe's 3 s bound, so the walk stops after 100 steps; writing the Python text probe now sits in its own guard, as reading it does. A test pins that a probe whose read never returns is given up and the render stands. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
There was a problem hiding this comment.
🟡 Changes recommended
Two moderate active findings remain: the SVG/HTML text walk double-counts ECharts and bokeh labels and uses up the 400-label cap, and plot_texts drops the labels_truncated flag, so a plugin-drawn title can be missing and wrongly reported as absent.
2 open issues
What changed in this PR
The agent's reply could not quote the texts of most plots. Only matplotlib, seaborn and plotnine reported the texts a plot draws, so the root got texts: null for the other libraries and told users it could not read the title back. This PR makes the render probes report drawn texts for pygal, altair, lets-plot, Chart.js, D3, ECharts, Highcharts, MUI X, plotly and bokeh. It also stops the root from receiving a null texts and adds a per-library count of shipped runs without texts to the eval report. ggplot2 and Makie are out of scope and follow in a separate PR.
Changes:
- Text probes for pygal, altair and lets-plot run in the Python render harness. They read the SVG or Vega scenegraph each library produces and classify texts by the library's own roles.
- Text probes for the browser-rendered libraries run in
render.mjsafter the screenshot, in sandbox mode only. They read SVG and HTML texts by each library's classes, and Chart.js and bokeh texts from their chart models. - Missing drawn texts no longer reach the root as
null. The eval report counts shipped runs without texts per library, and the matrix prints a note when there are any. The G12 decimal-mark gate now runs only when the probe includestick_axes.
| File | Description |
|---|---|
| tests/unit/automation/test_js_render_sandbox.py | Adds tests for the browser text probe: Chart.js, D3, Highcharts, ECharts, MUI X, tampering, and a probe that never returns. |
| tests/unit/automation/test_js_render_figure.py | Adds tests for the plotly and bokeh text probes in figure mode. |
| tests/unit/agents/runtime/test_runtimes.py | Checks that collect reads the text probe and ignores a probe that is not a JSON object. |
| tests/unit/agents/runtime/test_grounding.py | Tests the pygal, lets-plot and Vega probe readers, and that a result without texts gives the root no texts key. |
| tests/unit/agents/runtime/test_density_locale_gates.py | Limits the G12 decimal-mark gate to probes that include tick_axes. |
| tests/unit/agents/runtime/test_catalogue_flow.py | Covers a text probe that grounds the reply while the reviewer still checks the requested texts. |
| tests/unit/agents/evals/test_report.py | Tests the per-library count of shipped runs without drawn texts. |
| tests/unit/agents/evals/test_matrix.py | Checks that the recorded text roles are kept and that no text is recorded. |
| docs/concepts/agent-network.md | Documents the new probes, the root's handling of missing texts, and the reviewer split. |
| changelog.d/agent-plot-texts.md | Changelog fragment for the text probes and the null fix. |
| automation/js-render/render.mjs | Adds writeTextProbe and the per-library text readers, run after the screenshot in sandbox mode. |
| agents/evals/report.py | Counts shipped runs without drawn texts (untexted) and shows them in the summary. |
| agents/evals/matrix.py | Records the text roles of each run and prints a note when runs lack texts. |
| agents/anyplot/render/runtimes/python.py | Reads the probe for every Python library and renames the reviewed-text set. |
| agents/anyplot/render/runtimes/javascript.py | Reads the browser probe in collect and updates the review-texts notes. |
| agents/anyplot/render/png.py | Adds read_probe, which returns the probe JSON object or None. |
| agents/anyplot/render/harness.py | Adds the pygal, altair and lets-plot probe readers and wraps their PNG exports. |
| agents/anyplot/render/gates.py | Limits the number-format gate to probes with tick_axes. |
| agents/anyplot/render/contract.py | Updates the review_texts docstring to match the new probe behaviour. |
| agents/anyplot/prompts/root.md | Tells the root to describe changes without texts when none are reported, and not to call a text missing when it appears in texts. |
| agents/anyplot/plugins/tool_safety.py | Drops texts from the fenced shows block when it is null. |
| agents/anyplot/pipeline.py | Reports drawn-text roles per run and checks requested texts against them, except for libraries the reviewer checks. |
| agents/anyplot/grounding.py | Updates the docs and the unverified-texts line for the new probes. |
Review effort: Lite
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Review of #12503 (Copilot) and the lead's conditions: - A text probe that fails or does not answer within 2 s drops its labels and writes {"error": <class>}; the pipeline logs it as `probe_error` on the `pipeline_render` line (closed vocabulary, `grounding.PROBE_ERRORS`). An SVG over 8 MB is not parsed. - render.mjs no longer walks the DOM an ECharts instance or bokeh draws into: their display list and models report those texts, so an SVG renderer counted each one twice (Copilot). - root.md: `texts` can leave out a drawn text, so the reply never says the plot lacks a title, label or legend; only `requested_not_found` says a requested text is missing (Copilot). The rule that texts are quoted only from `texts` stays. - Evals: `untexted` counts only libraries whose render reports texts (`RuntimeAdapter.text_probe`, False for ggplot2 and Makie until their probe exists); `unbacked_quotes` counts passages a reply to a shipped plot quotes that no drawn or requested text holds; `probe_errors` counts failed probes. - Tests: the wrapping of each library's PNG export, a broken or linked probe file, a failed probe through the pipeline, the ECharts SVG renderer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
Owner decision of 2026-11-01: #12503 covers all 15 libraries. - text_probe.R, R's user profile on the R row (`R_PROFILE_USER`): traces `ggsave` once ggplot2 loads and leaves the saved plot's texts, read from its grob tree by layout cell, as texts-<theme>.json. The grob is built on a null PDF device; any failure writes {"error": "Error"}. - text_probe.jl, a second `-L` on the Julia row after the workload: an atexit hook reads the current figure's blocks (axes, legends, labels, colorbars, text plots in an axes) once plot-<theme>.png exists; any failure writes the exception type and prints nothing. - harness.collect_text_probe (exec mode, after a run that counts): reads that file without following a link or blocking on a pipe, under 4 MiB, checks every entry and applies the probe's caps, so one cap and one ordering hold for every language. - The renderer Dockerfile copies both files to /opt/anyplot/, the local backend mounts the checkout's, `RuntimeAdapter.text_probe` is True for every library, and the R and Julia runtimes read the probe. JULIA_FLAGS stay the toolchain smoke's; the text probe's `-L` belongs to the row. - ci-image.yml asserts the probes of its R and Julia renders. - Added render time (bar-grouped, median of seven interleaved pairs): ggplot2 +0.3 s, Makie +0.55 s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
- Makie: a `Label` is the title only when no axes has one and it is larger than every other label (panel letters of one size are no title); an Axis3 or PolarAxis title counts as a title; a hidden colorbar label is left out; a block that cannot be read is skipped, the others' texts stay. - ggplot2: a patchwork numbers its plots' cells (`title-2`), so the roles match by prefix. The cells stay read from the outermost in: a legend's own `title` cell sits inside `guide-box-*` (the Flash suggestion to read from the innermost would have made every legend title the plot's title). - Image tests for a patchwork and for Makie's labels and colorbar. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
Fable and Gemini Argon on #12503, sound with changes: 1. R: `ggsave` is wrapped, and the probe runs after a save that returned, so its second build (random numbers, warnings) never reaches the PNG; a test pins the PNG byte for byte against a save without the probe. A trace at ggsave's exit does not work: ggsave's own on.exit() replaces it. The device is restored in on.exit, the build's warnings and messages are muffled, and NA labels are dropped. 2. `subtitle` is a role of its own (ShownGroup, GROUP_LIMITS, KIND_GROUPS, gates.TEXT_KINDS, root.md, the kept groups). 3. ci-image.yml rebuilds the renderer image when only a text probe changes. 4. Highcharts: an inverted chart's axes are named by the side they are drawn on (verified on a bar chart). 5. D3: only the one rotated text in the outer tenth is the y axis label. 6. Makie: rich text reads as drawn, Axis3 and PolarAxis honour titlevisible, and their axis labels are read where visible. 7. ECharts: axis names and ticks by the axis view's own mark (`anid`, 6.1.0), so a category spelled as the axis name stays a tick. 8. The probe is written with O_NOFOLLOW (Python and Node). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
Brings in #12505 and #12503. The one conflict, the messages row of docs/reference/api.md, takes main's `?theme=` wording and keeps this branch's summary clause. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
## Summary - Owner decision of 2026-11-01 after the user test: "Replies should be short and to the point, no waffle, and follow-up questions only when they really matter." `root.md` gets a "Replies" section. A reply leads with the result in two or three sentences, asks a question only when the turn cannot go on without the answer or the answer changes the data or the plot materially, closes with no invitation, and acts on the user's answer in the same turn. Brevity never drops a notice the data rules require: the loader's "reads", "keeps time" and "converts" lines, data changes with their counts, `requested_not_found`, binding warnings, tool errors and limits. The step-1 summary no longer asks a question by default and lists nothing the data does not have. - The "what changed" notes are now written in the language the user chats in and in plain words, without the code's own names such as `load_user_data()`, without what stayed as it was, and a fix lists only itself (`adapter.md`, and the self-review line in `pipeline.py`). - The prep eval measures every reply: words, questions, and whether it closes with a question. A closing question in a turn that was not blocked, or a reply over 100 words, is a reply miss, gated at 10 % (`--max-reply-misses`). A turn is blocked when the case is tagged `ask_back`, a data call failed, or a required role is left without a column. A case can send the page's `summarize_data` action, and four summary cases join the set. Decided and documented here: - **"Offer to ..." in `root.md`:** the information stays, said as one short statement, through one rule in "Replies" rather than an edit of every passage. This keeps the diff away from the lines #12503 and #12504 change. Only `needs_attention` drops its "offer to fix one" outright: the user asks when they want a fix. - **Notes language (lead, after #12504 merged):** the reply language, since the notes explain the plot to the user. `AdaptRequest.reply_language` carries the session language only when it differs from the plot language, fenced as `<reply_language>` after the plot language. The adapter then writes `changes` in it and keeps the plot text in the plot language. Without the field the two languages are the same. - **No server-side deduplication of notes:** the repeats in the test were paraphrases from the self-review call, which no string comparison catches. The prompt and the self-review line now address the cause, and the final chat run showed no repeated note. - **Length bound:** 100 words, about the five sentences `root.md` allows at most. ## Measurements Claude Haiku 5.5 on Vertex AI `eu`, $0.985 in all. Seven scripted chats rebuilt from the user test (A1, A3, B1, B4 and three summaries, two of them run twice), through `/v1` in process with the deployed renderer, 20 replies per arm. The baseline arm ran on the `origin/main` prompts. | | main | this branch | |---|---|---| | Words per reply, mean (median, max) | 58.7 (55, 80) | 50.4 (52, 71) | | Questions per reply | 0.75 | 0 | | Replies that close with a question | 13 of 20 | 0 of 20 | | Summaries: words, with a question | 60.6, 11 of 11 | 55.1, 0 of 11 | | Plot replies: words | 50.2 | 37.0 | - **A3, "The scores are out of 100.":** on main the root called no tool and answered "If you want the value axis to run from 0 to 100, tell me". The next plot kept the data's maximum. On this branch the root calls `plot_pipeline` with "Set the value axis to run from 0 to 100 ...", and the axis runs from 0 to 100. - **B1 notes:** on main they were English under a German reply and named `loadUserData()`. On this branch they are German, with no code names. The prep eval (55 cases) ran with the same eval code on both arms: | | main | this branch | |---|---|---| | Exact operation match | 53 of 55 | 55 of 55 | | Reply words, median (max) | 69 (123) | 57 (121) | | Questions per reply | 0.50 | 0.04 | | Replies that close with a question | 20 of 52 | 2 of 52 | | Reply misses (gate 10 %) | 21 of 52, FAIL | 3 of 52, PASS | The main arm ran before the open-role rule joined the blocked definition, so its reply-miss count is not comparable one to one. The closing-question and question counts are. Pre-PR Gemini 3.8 Flash check ($0.21, three findings): - Applied: a statement of what is possible may close a reply, and only an invitation may not. Applied: the policy test covers all seven loader prefixes. - Skipped: counting a question anywhere in the reply as a reply miss. The brief defines the check as a closing question, and the report still shows questions per reply. Copilot review (one round, three findings): - Applied: the true median for an even number of replies, with a test. Applied: the golden adapter-prompt test is renamed for what it now pins. - Skipped, as a false positive: the "high" `NameError` on `PrepCase`'s return annotation. The module has `from __future__ import annotations` and runs on Python 3.14, and its tests pass. ## Plan N/A (brief from the lead session; owner decision of 2026-11-01) ## Test plan - [x] `ruff check .`, `ruff format --check .`, `mypy api core agents` - [x] `pytest tests/unit tests/integration -n 6`: 25,857 passed. New tests cover the "Replies" rules and every protected notice, the action-or-message case model, the reply measurement (closing question, long reply, and blocked by `ask_back`, by a failed call or by an open role), the reply gate, and a scripted summary run. - [x] Live chat scenarios and the prep eval, as above. - [x] After merging `origin/main` (#12505, #12503, #12504) and the reply-language commit: `ruff`, `mypy api core agents`, and `pytest tests/unit tests/integration -n 6` with 25,934 passed. Two flow tests through `/v1` pin that a German plot under English replies gets its notes in English and the reverse, and that no `<reply_language>` block appears while the two languages agree. - [ ] Not measured live: the last notes rule ("never what stayed as it was") and the reply-language notes, since the $1 measurement budget was spent ($0.985). ## Open points - The server's own loader lines ("R reads ...") stay English in the notes list, because the root reads them by their English prefixes. - The regression matrix does not read replies, so plot-turn replies were measured with a scratch driver only. Adding a reply measurement to `matrix.py` would collide with #12503. - Haiku still adds "confirm the data in the data panel" to about half of the summaries: one short sentence, left as it is. https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8 --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

Summary
The reply now has the plot's texts in all 15 libraries. In the user test of 2026-11-01, replies for Chart.js, bokeh and pygal said the title and labels could not be read back, and a Makie reply offered a title the plot already had. Only 3 of 15 libraries reported drawn texts (matplotlib, seaborn, plotnine), so
shows.textswasnullfor the other 12. The probe now reports them for:harness.install_text_probes: pygal from the SVGcairosvg.svg2pngreceives, altair fromvl_convert's scenegraph, lets-plot from its bridge's SVG.render.mjswriteTextProbe, in sandbox mode only, after the screenshot. It reads SVG and HTML texts by each library's classes, D3 ticks by their axis'stext-anchor, ECharts' zrender display list by owning view, and the Chart.js and bokeh chart models.text_probe.R, R's user profile on the R row. It wrapsggsaveand, after a save that returned, reads the saved plot's grob tree by layout cell, a patchwork included.text_probe.jl, a second-Lon the Julia row. Itsatexithook reads the current figure's blocks.Both of the last two leave
texts-<theme>.json, which the harness's exec mode turns into the probe under the same caps (harness.collect_text_probe).The root never sees a null
texts.fence_pipeline_notesdrops the key, and root.md drops the sentence about unread texts. The rules that remain:texts. Withouttexts, the reply describes the change and names, quotes or asks about no text of the plot.textscan leave a drawn text out, so the reply never says the plot lacks a title, label or legend. Onlyrequested_not_foundsays a requested text is missing.This is structural, with no filtering of model text.
The eval checks are code, scoped per library. The
pipeline_resultline carries the roles ofshows.texts, never a text, plustexts_expected(RuntimeAdapter.text_probe, now True for all 15). The report counts:untexted: shipped runs without drawn texts where the library reports them.unbacked_quotes: passages a reply to a shipped plot quotes that no drawn or requested text holds. They are compared in process and only the count is kept.probe_errors: failed probes by class.The matrix prints a note when either of the first two is above zero.
Best-effort probes
{"error": <class>}, whichpipeline_renderlogs asprobe_error(a closed vocabulary,grounding.PROBE_ERRORS).tryCatchon a null PDF device, restored inon.exit, with its warnings muffled, and writesError.ERROR:line stays the plot's. A block that cannot be read is skipped.O_NOFOLLOW, in Python and in Node.O_NOFOLLOW|O_NONBLOCK, as a regular file only, under 4 MiB, and checks every entry.Added render time (local Docker backend, catalogue bar-grouped, light)
Decisions (decide-and-document)
review_texts, renamedTEXTLESS_LIBRARIEStoREVIEWED_TEXT_LIBRARIES). The new probes ground the reply only. Canvas libraries read their texts from the chart model, which a plugin's own drawing is not in, so a code check could claim a drawn text missing and start a false repair. The Chart.js and bokeh probes setlabels_truncated: true. A library can switch to the code check once its probe is proven.tick_axes, which only matplotlib's probe writes. Its advice names matplotlib formatters.UNVERIFIED_LINEwording no longer blames the renderer. It now reads "could not be checked against the render".JULIA_FLAGSstay what the toolchain image's build smoke runs. The text probe's-Lbelongs to the Julia row ((JULIA_BINARY, *JULIA_FLAGS, "-L", JULIA_TEXT_PROBE)), because the probe file lives in the renderer image, not the toolchain image. No toolchain rebuild.Deploy order
agents/renderer/Dockerfilecopiestext_probe.Randtext_probe.jlto/opt/anyplot/, andcommands.pynames them (R_PROFILE_USER, the row's second-L). Both ship in the same image, built bydeploy-renderer, so a row never names a file its image lacks.commands.pyonly for the local Docker backend. That backend mounts the checkout's probe files over the image's, so a developer's older local image works too. Its remote backend runs no command.probe-<theme>.jsonfor every language. With new agents and the old renderer, the root just has no texts for those libraries (no null), anduntextedwould show it.ci-image.yml), along with the new Julia argv.cloudbuild.yamlordeploy_smoke.shchange.Reviews
Gemini 3.8 Flash, before the PR opened: five findings, two applied (a bounded ECharts owner walk, a guarded probe write) and three rejected:
except A, B:as a syntax error. PEP 758 allows it on Python 3.14.Copilot, two medium findings, both applied:
textsproves nothing.Gemini 3.8 Flash on the R and Julia part (1a64081..): four findings, three applied and one rejected:
Labelis the title only when it is the one largest, Axis3 and PolarAxis titles count, and a hidden colorbar label is left out.titlecell the plot's title. Patchwork's numbered cells (title-2) now match by prefix instead.Second opinion (Fable and Gemini 4 Argon, 9a596ac): sound with changes, nothing blocking. All eight points applied in 3b15e65:
ggsavereturned, not at its entry. Atrace(exit=)does not work there, becauseggsave's ownon.exit()replaces it, soggsaveis wrapped. The gtable is not captured atggplot_gtable's exit, because a patchwork assembles its table elsewhere. The device is restored inon.exit, warnings are muffled, and NA labels are dropped.subtitleis a role of its own.ci-image.ymlrebuilds the image when only a text probe changes.titlevisibleand their axis labels.anidmark (ECharts 6.1.0).O_NOFOLLOW.Owner follow-up (outside this PR)
deploy-renderer(europe-west4) listsagents/anyplot/render/harness.pyandconvert.pyinincludedFiles, but notagents/anyplot/render/text_probe.Rortext_probe.jl.agents/renderer/**. A later change to only a probe file would not redeploy the renderer.agentic/docs/project-guide.md, the trigger import route).Plan
N/A (brief from the 2026-11-01 user-test triage; owner decision: all 15 libraries in this PR)
Test plan
uv run pytest tests/unit tests/integration -n 8: 25,867 passed. ruff, ruff format,mypy api core agentsand hadolint on the renderer Dockerfile are clean.node_modulesand Playwright 1.64.1, all passing. They include probe tests for Chart.js, D3, Highcharts, ECharts (canvas and SVG), MUI X, plotly and nested-factor bokeh, the tamper and never-returning cases, and the catalogue page byte for byte unchanged without sandbox mode.tests/unit/agents/code/test_text_probe_image.py, run against the local renderer image, 6 passed. It covers ggplot2 and Makie roles, a patchwork, Makie labels and colorbar, and a failing R and Julia probe with the render standing. CI skips it without the image.claude-haiku-5-5on Vertex, $0.107 in total:ok, withuntexted0,unbacked_quotes0 andprobe_errors0.tests/unit/agents/code/test_text_probe_image.py9 passed, the JS harness tests 121 passed, the local gates 25,873 passed.https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8