Skip to content

fix(mcp): bound chart decode, transcript walks, and repeat-delivery spend - #1

Closed
jmesparteroup wants to merge 2 commits into
jchu96:feat/deliver-turn-charts-upstreamfrom
pymc-labs:fix/deliver-turn-charts-review-followups
Closed

jmesparteroup wants to merge 2 commits into
jchu96:feat/deliver-turn-charts-upstreamfrom
pymc-labs:fix/deliver-turn-charts-review-followups

Conversation

@jmesparteroup

Copy link
Copy Markdown

Summary

Stacks on pymc-labs#129 (feat/deliver-turn-charts-upstream) and folds into it on merge; second-pass review follow-ups. No issue closes here — pymc-labs#129 carries the Closes for pymc-labs#119 (and pymc-labs#124 still needs adding there, per the thread).

  • A compact PNG could decode to gigabytes: Pillow's decompression-bomb error fires only above ~179M pixels, so a 690 KB 13000x13000 chart cleared the 10 MB byte cap and peaked over 2 GB RSS in the embed thread (reproduced locally). Decodes refuse anything above 25M pixels before load().
  • Artifact object keys ended in the agent's filename, so a later turn rewriting chart.png repointed an earlier turn's still-live presigned URL, and two same-named files in one turn collapsed into one object. The file id is now part of the key.
  • Uploads run in a thread asyncio.timeout cannot cancel; one started near the delivery deadline completed after the caller gave up, stranding an unreferenced object in the bucket. Uploads no longer start inside the final 10 s of the 60 s budget.
  • File discovery paged at the SDK default of 20 and truncated at 200 candidates before sorting on an ordering the API does not document; it now requests full pages and logs scan_truncated when the cap drops candidates. Configured artifact settings with no constructed store log store_unconfigured instead of silently disabling URL delivery.
  • The transcript walks in ask and deliver_turn_charts were unbounded, and each page costs an ownership retrieve plus an events list; both walks stop after 20 pages.
  • deliver_turn_charts is ungated and unmetered, and every call re-downloaded each chart from the Files API and re-wrote the artifact store. A delivered result is cached in-process per turn boundary (at most 5 minutes, never longer than its presigned links live), so client retry loops stop multiplying operator-side spend.
  • ask raised on any status outside idle/rescheduling/running, but the status value set is upstream-controlled. Only terminated is terminal; an unmodeled transient status keeps polling until the deadline instead of failing a turn admission already billed.
  • AskResult advertised image_blocks in its output schema while excluding it from every dump. The field is out of the schema, and chartless and charted replies share one content shape: prose text first, image blocks after, structured payload throughout.

Behaviour changes

  • Every ask/deliver_turn_charts reply is a prose-first ToolResult; a chartless ask previously returned the serialized model as structured JSON only.
  • A repeated deliver_turn_charts for the same completed turn within the cache window returns the earlier result without re-contacting the Files API or the bucket.
  • Sessions deeper than 2000 events (20 pages) without a turn boundary now refuse with the existing no completed turn boundary error instead of walking the full transcript.

Testing

  • Twelve new tests, each watched failing first. The 2 GB decode was reproduced out-of-band with a real 13000x13000 PNG; CI pins the pixel gate via a patched cap instead.
  • uv run pytest: 4670 passed, 6 skipped; the two notebook-host self-chown failures are the known macOS PermissionErrors, byte-identical to the PR base.
  • uv run pyright — 0 errors. uv run ruff check . and ruff format --check . clean. uv run lint-imports — 6 contracts kept.
  • Tool schema snapshot unchanged (it covers input schemas; the AskResult output-schema change has direct assertions instead).
  • Not tested live: everything on feat(mcp): deliver completed-turn charts pymc-labs/daimon#129's outstanding human-UAT list (real bucket round-trip, hosted-client rendering) is unchanged by this branch.

Checklist

  • Tests added or updated for any behavior change
  • uv run pytest passes locally (two pre-existing macOS notebook-host failures, identical on base)
  • uv run pyright passes locally (strict mode)
  • uv run ruff check . is clean
  • uv run lint-imports passes (package boundary contracts)

A compact PNG can decode to gigabytes: Pillow's decompression-bomb error
only fires above ~179M pixels, so a 690 KB 13000x13000 chart cleared the
byte cap and peaked over 2 GB RSS inside the embed thread. Decodes now
refuse anything above 25M pixels before load().

Artifact keys ended in the agent's filename, so a turn that rewrote
chart.png repointed an earlier turn's still-live presigned URL, and two
same-named files in one turn collapsed into one object. The file id is
part of the key now.

Uploads run in a thread asyncio.timeout cannot cancel; one that started
near the delivery deadline completed after the caller gave up and
stranded an object with no reader. Uploads no longer start inside the
final ten seconds of the budget.

File discovery paged at the SDK default of 20 and truncated at 200
candidates before sorting on an ordering the API does not document; it
requests full pages and logs when the cap drops candidates. Configured
artifact settings with no constructed store also log instead of silently
disabling URL delivery.
The transcript walks in ask and deliver_turn_charts were unbounded, and
each page costs an ownership retrieve plus an events list, so a long
session could outlast any hosted client's tool timeout before delivery
even began. Both walks stop after 20 pages.

deliver_turn_charts is ungated and unmetered, and every call
re-downloaded each chart from the Files API and re-wrote the artifact
store, so a client retry loop turned into unbounded operator spend. A
delivered result is cached briefly per turn boundary, never longer than
its presigned links live.

ask raised on any status outside idle/rescheduling/running, but the
status value set is upstream-controlled; an unmodeled transient status
failed a turn admission had already billed. Only terminated is terminal;
anything unknown keeps polling until the deadline.

AskResult advertised image_blocks in its output schema while excluding
it from every dump, and a chartless ask returned a JSON blob where a
charted one returned prose. The field is out of the schema and every
result carries the same prose-first content shape.
@jmesparteroup

Copy link
Copy Markdown
Author

Closing — restacking this onto a base branch in pymc-labs/daimon instead.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant