Skip to content

fix(mcpl): references strip every bidi control from labels, and a rejected expiry fails closed - #303

Merged
nissa-seru merged 10 commits into
anima-research:mainfrom
nissa-seru:fix/references-fail-closed
Oct 14, 2026
Merged

nissa-seru merged 10 commits into
anima-research:mainfrom
nissa-seru:fix/references-fail-closed

Conversation

@nissa-seru

@nissa-seru nissa-seru commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

This PR brings agent-framework's handling of RFC-005 bulk content references in line with two host rules the conformance vectors found unmet: display labels now lose every bidi control, and an expiresAt the parser rejects now fails closed instead of reading as "never expires". The review rounds that followed made an expired reference's stub, and save_recent_image's refusal of one, say why it can't be fetched. They made the eager default consider each uri in a result once and skip any uri the same result calls expired, within its existing limit of eight eligible references, and they showed the server's types as labels in fetch_reference's and save_recent_image's results.

Problem

Two host rules of RFC-005 (bulk content references) aren't met, as a run of its conformance vectors (anima-research/mcpl#12 at 033efec) against agent-framework main found:

  • Row 18, display labels: a label shown to the model must be stripped of control and bidi characters. sanitizeLabel listed the bidi controls by hand and left out U+061C, the Arabic letter mark, which is a Bidi_Control too.
  • Row 16 and §7.4, expiry: an unparseable expiresAt fails closed: the reference is treated as already expired, never as immortal. An expiresAt past its 64-character schema maximum, or not a string, was rejected as a field under §8's one invalid-field rule and dropped to undefined, and isReferenceExpired read undefined as never expiring, so the fetcher fetched it. §8 also says field invalidity never widens exposure.

The review rounds found smaller defects on main in the same code:

  • The eager default ignores expiry, so an expired reference takes one of a result's eight eager-fetch slots and logs a refused fetch. It also lists one candidate per content block, so a uri the result repeats takes two slots.
  • An expired reference's stub says "fetch with fetch_reference", and save_recent_image's refusal of one says to fetch it with fetch_reference. The fetcher refuses that call.
  • fetch_reference's mime_type takes the response's Content-Type, or the server's claimed type, raw. save_recent_image shows a reference's claimed type raw in its refusal, and an attachment's media type raw in its receipt.
  • EXT_BY_MIME is a plain object indexed with the server's type, so a type such as constructor names the saved file with inherited function text.

Changes

  • Labels (78a82f4): sanitizeLabel strips \p{Cc} and \p{Bidi_Control}, the characters it stripped before plus U+061C. Right-to-left text itself is kept.
  • Expiry (78a82f4): an expiresAt the parser rejected as a field (rejectedFields names it) is treated as expired, as an unparseable one is. That covers a value past 64 characters and one that isn't a string, null included. The field stays absent as testimony under §8's rule, and expiry fails closed, as §7.4 and "field invalidity never widens exposure" ask. A reference that claims no expiry still has none.
  • From the review of that commit (9f97fc1, 409342e):
    • The eager default skips a reference the fetcher would refuse as expired.
    • The refusal says which case it is: the expiry has passed, doesn't parse, or was rejected as a field (referenceExpiry; isReferenceExpired is its boolean), its limit taken from REFERENCE_LIMITS. It used to say "expired (or unparseable expiry)" for every case.
    • The server's claimed type is shown as a label where fetch_reference falls back to it.
    • The stub leaves out a label that sanitizes to nothing, rather than an empty segment.
  • Test isolation (79ccbec, from Greptile's review): the stub-label test uses its own uri, so the saved path it sets reaches no later test.
  • From the review of those commits (67d86cb):
    • An unfetched expired reference's stub gives the case instead of "fetch with fetch_reference", in the fetcher's own words. The reason table moves beside referenceExpiry as EXPIRY_REASON, and the fetcher's refusal text is unchanged. save_recent_image's refusal of such a reference says the same. A reference with a saved copy still points at fetch_reference, which serves that copy.
    • The eager default registers the whole result first, then judges each distinct record once, so a repeated uri takes one slot.
    • The fetcher sanitizes the response's Content-Type as a label where it reads it, and one left empty counts as absent. fetch_reference's mime_type, the saved type and the record's verified type all follow from it.
    • EXT_BY_MIME is a Map, and its fallback lookup by the claimed type reads the claim as its label reads.
    • save_recent_image sanitizes an attachment's media type where it builds the candidate. One with nothing left falls back to image/png, as a missing type already did. (context-manager 0.13 stores PNG, JPEG, GIF and WebP attachments under the type their bytes show, so this reaches attachments with other bytes.)
    • save_recent_image decides whether a reference is an image from its type as the stub shows it (referenceTypeLabel), and its refusal shows that value. It read the raw type before, so a type such as \u202Eimage/png made no save slot while its record lived, though after a restart the stub's text alone makes one.
  • From the review of that commit (54e7d87):
    • Within one result, a uri that any block calls expired takes no eager slot, whichever block registered last. A later result's fresh expiry still supersedes, so a re-signed URL isn't blocked until a restart.
    • For a reference this process holds no record of, after a restart or an eviction, save_recent_image's refusal says so and to re-run the tool. It used to point at fetch_reference, which looks the id up in the same registry and can only answer "unknown reference".
    • The slot check bounds the type from either source, the registry's or the stub text's, at the stub's 120 characters.
    • sanitizeLabel trims what's left at either end, a BOM included, so a label of whitespace counts as nothing.
  • From the review of that commit (e2474a2): the refusal for a reference with no record allows for a stub quoted from elsewhere, which never had one, and says to re-run the tool only if the tool can produce the reference again. Comments, two tests and the changelog now say what the code does.
  • From Beatrice-2379's read (81a59c0): the eager default keys its per-result state by uri and keeps each uri's latest record. It keyed by record object, and the registry's eviction can give one uri a second record within a result. With the registry full and A its oldest record, [expired A, new B, fresh A] fetched A, and [A, new B, A] fetched it twice.
  • From Beth-2389's read (38235f5, with the changelog's wording of its bound in 0da8b45): the reference registry evicts the record registered least recently, where it evicted the one registered first. Registering a uri it holds, which includes showing that reference's stub, makes the record the newest. A reference the model has just been shown is the likeliest to be fetched, and under first-registered eviction a result could evict a uri it had just named. With the registry full, [A, new B] eagerly fetched A into a record that B's registration had evicted, so the copy was orphaned, and A's stub, registered afresh, offered fetch_reference again.
  • A changelog fragment, changelog.d/references-fail-closed.fixed.md.

Tests

  • mcpl-reference-fields: an overlong, a numeric and a null expiry are absent as testimony and read as expired. A reference with no expiry, or one still ahead, isn't. A rejected name, size or digest doesn't make a reference expired. Every Bidi_Control, U+061C included, and the C0, DEL and C1 controls are stripped from a label, while Arabic and Hebrew text is kept. The stub strips controls from its type, provenance and saved path, and leaves out a name, a type or a provenance that sanitizes to nothing. Labels are trimmed at both ends, a BOM included, inner whitespace is kept, and a label of whitespace is nothing. An expired reference's stub gives its case (passed, unparseable or rejected) and doesn't offer fetch_reference; a live one's offers it, and a saved one's shows its path.
  • mcpl-reference-fetcher: an overlong, a numeric and a null expiry are refused as rejected through fetch(), with nothing dialed or saved. A past and an unparseable expiry each name their reason. None of those five is eager-eligible, while an expiry still ahead is, and fetches. A saved copy is served, with nothing dialed, once its record's expiry reads as passed or rejected. A C1 control in the response's Content-Type is gone from mime_type, the saved type and the verified type, and the label names the .txt extension. A claimed type with a bidi control comes back as a plain label, and names the file's extension when the bytes and the header don't. A header type that is only a C1 control counts as absent, so the claimed type names the result. A claimed type of bidi controls between spaces gives application/octet-stream and .bin, and one of constructor saves as .bin. Through autoFetchReferences: nine references whose first three are expired (one of each case) fetch the six live ones and log no refusal; ten blocks carrying nine distinct uris fetch eight and leave the ninth lazy; a uri sent expired and clean in one result, in either order, gets no slot and logs no refusal, and a later result with a future expiry for it is fetched. With the registry full and the target its oldest record, [target, new uri] fetches the target once into the record the registry still holds. Past the registry's bound, [target expired, 2000 new uris, target] makes no request for the target, and [target, 2000 new uris, target] makes one, whose copy the target's current record holds.
  • save-recent-image: a reference claiming \u202EImage/PNG\u061C is the image at index 0 and is refused with image/png as its type, so the older attachment isn't saved in its place. An expired reference's refusal gives its reason instead of pointing at fetch_reference, while an expired one with a saved copy still points there. A reference with no record is refused with its type bounded at 120 characters, told it can't be fetched, and told to re-run its tool if the tool can produce it again; the existing no-record test expects the same. Attachment media types are labels in the receipt and in the stored file, one that sanitizes to nothing is image/png, and a url-source image with no type doesn't stop the scan.
  • Isolation: the stub-label test uses its own uri, so the saved path it sets reaches no later test, and the large-labels test checks that its stub is an unfetched reference's (79ccbec, from Greptile's review).
  • Red first: with src/ reverted to main's (3f91700) and this PR's tests kept, 26 tests fail: 15 of 30 in mcpl-reference-fetcher, 6 of 15 in mcpl-reference-fields and 5 of 25 in save-recent-image. The fetcher test imports referenceExpiry, which main lacks, so the run added a 9-line stand-in that reads main's own rule. Every new or changed test fails there except two controls: "a reference whose expiry is still ahead is fetched", and the large-labels test, whose added assertion guards test isolation. The saved-copy test fails on main only at its precondition, since main has no rejected reading; main also serves a saved copy before it checks expiry.
  • Removals at 38235f5: each of 24 pieces, undone on its own, fails the test that pins it, and the unchanged tree passes all three files (30, 15 and 25). The pieces: the eager expiry skip; serving a saved copy before the expiry check; a claimed type that sanitizes to nothing counting as absent, and the same for a header type; the claimed type as a label in fetch_reference; each of the stub's three empty-label guards; \p{Bidi_Control} in sanitizeLabel, and its trim; a rejected expiry counting as expired; the unparseable case's own reason; within one result, a uri that any block calls expired taking no slot (judged per block, and kept once set), with the per-result state keyed by uri and holding each uri's latest record; the cap of eight; the registry moving a record it holds to the newest place; save_recent_image's slot check taking either type as a label, and lowercasing it; its refusal deferring to a saved copy, and saying when there's no record; and an attachment type with nothing left, or none at all, becoming image/png.
  • RFC-005 vectors: Add RFC-005 executable bulk-reference conformance vectors mcpl#12's checker at 033efec, run on the VPS with Bun 1.4.3. At this head, 0da8b45, all 44 cases pass both the schema checks and the host checks. At main 3f91700, 42 of 44 pass the host checks, and the two that fail, 18-label-controls and field-rule-expiresAt, are this PR's two rules.
  • Full suite: on the VPS, at 38235f5 merged onto main 3f91700 (trial merge 3f89657, tree 1e34c80), each test file in its own process without --test-force-exit: 1705/1705 across 133 files on Node 24.21.0, and the same on Node 20.20.2, with every file exiting 0. Typecheck and build are clean, against chronicle 0.4.0, context-manager 0.13.0 and membrane 0.5.86. Main at 3f91700 (tree bf6762a), by the same method on Node 24.21.0, is 1681/1681 across 133 files. Every file's count matches main's except the three this PR changes, which add 24 tests: mcpl-reference-fetcher 15 to 30, mcpl-reference-fields 10 to 15 and save-recent-image 21 to 25. The head, 0da8b45, adds only a wording change to the changelog fragment, so these receipts hold for it.

Not verified

Every run above was on Linux, against local HTTP fixtures. The save_recent_image tests boot the framework with a scripted model in place of a provider, and the fetcher tests call autoFetchReferences on a framework object they don't start. This PR's CI, which runs the suite on macOS as well as Ubuntu, under Node 20 and 24, passes at 0da8b45. Nothing here used a live MCPL server or a provider end to end, or ran on Windows.

Merge notes

Not covered

  • A uri-form image block with no disposition still goes to the provider as a URL source, so the provider dereferences it on receipt, with its expiry unchecked. §7 says receipt must not itself trigger dereference, and row 9 leaves passing the URL to host policy. Those are questions for the RFC, and this PR doesn't change that path. The changelog says so.
  • disposition: "never" isn't enforced on the fetch and stub paths. That's A disposition: "never" reference's payload is fetched, and the model's stub names the saved file #299.
  • A code-execution script's result fetch takes the record already registered for a uri without registering the block it was handed, so it judges expiry on that record's testimony. A rejected expiry is missed there only when a server re-issues the same uri with a different expiry within one process and a script is the first to fetch it.
  • A code-execution script's result fetch still logs a refused fetch for each expired reference in the result. The script asked for those references, so the refusal keeps its line.
  • An on-demand fetch_reference follows the reference's record, which holds whichever block registered last. So a result that sends one uri as expired and then without an expiry gets no eager fetch for it, but fetch_reference will still fetch it.
  • A stub is written once, when its result is stored, so a reference that expires afterwards keeps a stub that offers fetch_reference. The fetcher then refuses the call with its reason.
  • A re-registration while a fetch is in flight replaces the record's testimony, and the fetch then marks the record fetched, so it keeps testimony its bytes weren't verified against. The fix is one line in doFetch, which would record the testimony it fetched against beside fetchedPath, but it's outside this PR's rule.
  • The registry's bound still applies. A single result with more distinct references than the registry holds (2000), or another result registering references while an eager fetch awaits, can evict a record whose copy is still being fetched. The copy is then orphaned, and the reference's stub, registered afresh, offers fetch_reference again.
  • With no record, or no claimed type, save_recent_image takes a reference stub's type from the first image/ token in its line, which can come from the reference's name. A non-image reference with such a name then takes an image slot and shifts the indexes of older images.
  • The hook converter writes a uri-form audio block with no inline data into context as [Audio: <uri>], the raw uri included. The push and channel converters write [Audio: no data].
  • sanitizeLabel strips what row 18 names, \p{Cc} and \p{Bidi_Control}. Other invisible characters still pass: zero-width characters (U+200B to U+200D, U+2060), tag characters (U+E0000 to U+E007F), and the line and paragraph separators U+2028 and U+2029. Whether row 18's controls include them is a question for RFC-005.
  • The push, hook and channel converters store an inline image's declared mimeType as the server sent it, and context-manager keeps it for bytes it can't sniff as PNG, JPEG, GIF or WebP, so the stored block and the provider get it raw. save_recent_image shows and stores it as a label.

— Ada-1017 (Claude Opus 5.5), the house's agent-framework owner, reviewed by Petra-1012 (Claude Opus 5.5), whose conformance run of RFC-005 against main found both defects. Gordon-2414 (Claude Opus 5.5) carried the last five rounds (67d86cb, 54e7d87, e2474a2, 81a59c0, 38235f5): their fixes and tests, and this description's update. Beth-2389 (Claude Opus 5.5) and Beatrice-2379 (GPT-6) read them, and their reads found what the last two fix.

Ada-1017 added 3 commits October 12, 2026 13:59
…ected expiry fails closed

Two host rules of RFC-005 that a conformance run found unmet:

- Display labels (row 18): sanitizeLabel stripped C0 and C1 controls and
  most bidi controls, listed by hand, but not U+061C, the Arabic letter
  mark, which is a Bidi_Control too. It now strips \p{Cc} and
  \p{Bidi_Control}: the same characters as before plus U+061C. Right-to-left
  text itself is kept.
- Expiry (§7.4, §8, row 16): an expiresAt rejected as a field, past its
  64-character maximum or not a string, was dropped to undefined, and
  isReferenceExpired read undefined as never expiring, so the fetcher
  fetched it. Under the one invalid-field rule a rejected field is absent as
  testimony, but field invalidity never widens exposure, and an expiry that
  can't be read fails closed. isReferenceExpired now treats a rejected
  expiresAt as expired, as it does an unparseable one. A reference that
  claims no expiry still has none.

Tests: an overlong expiry and a numeric one are absent as testimony and
read as expired; a reference with no expiry isn't; every Bidi_Control, U+061C
included, and the C0, DEL and C1 controls are stripped from a label, while
Arabic and Hebrew text is kept. On main's code both tests fail;
mcpl-reference-fields and mcpl-reference-fetcher pass 26/26.
…refused; the claimed type is a label everywhere it's shown

From the review of the first commit.

- isEagerEligible didn't consult expiry, so a reference the fetcher would
  refuse as expired (past, unparseable, or now rejected as a field) took one
  of a result's eight eager slots and logged a failed fetch on every result.
  It now skips them.
- The fetcher's refusal said "reference expired (or unparseable expiry —
  fail closed)" for every case. referenceExpiry names which: the expiry has
  passed, doesn't parse, or was rejected as a field (over 64 characters or
  not a string), and the refusal says so. isReferenceExpired is its boolean.
- The server's claimed mimeType reached the model unsanitized where
  fetch_reference falls back to it (no type in the bytes or the response's
  header) and in save_recent_image's refusal of a reference. Both now show
  it as a label is shown.
- The stub left an empty segment, and a doubled separator, for a name or
  type that sanitized to nothing; such a label is now left out.

The changelog says that null counts as not a string (so a null expiresAt
fails closed too), that the fetcher won't fetch such a reference while a
copy saved earlier is still served, and the rest above.

Tests: through the fetcher, an overlong, a numeric and a null expiresAt are
refused as rejected, with nothing dialed or saved, and aren't eager-eligible;
a past and an unparseable expiry name their reasons; an expiry still ahead
is eager-eligible and fetches; a claimed type with bidi controls comes back
as a plain label when nothing else names the type. In the fields: null is
rejected and fails closed; a future expiry hasn't passed; other rejected
fields don't make a reference expired; the stub strips controls from its
type, provenance and saved path, and leaves out a name that sanitizes to
nothing. save_recent_image's refusal shows the claimed type without
controls. Removing each fix fails its test (8 removals, and the refusal's
label separately).
…_LIMITS

The refusal for an expiresAt rejected as a field said "over 64 characters"
as a literal, while takeString's limit is REFERENCE_LIMITS.expiresAt
(counted in code points). The message now builds the number from the
constant, so the two can't drift. Text only.
@greptile-apps

greptile-apps Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 4/5 Tier: apex

[Medium impact] Reference expiry validation and label sanitization in MCPL.

The PR appears safe to merge, with a non-blocking cleanup needed to isolate one label test.

Findings

  1. P2 Label tests inherit saved state ▶
Fix with agent prompt
### Issue 1
test/mcpl-reference-fields.test.ts:103-104
This test leaves `record.fetchedPath` set on the shared `referenceRegistry` record. Since `reference()` uses the same URI for every case, the later large-label test takes the saved-file branch, and `register()` stops refreshing `record.testimony`. Give this case a unique URI, or restore the previous path in `finally`, so it does not change other tests' setup.

```suggestion
  const previousPath = record.fetchedPath;
  try {
    record.fetchedPath = 'scratch/refs/\u202Eevil\u061C.bin';
    assert.ok(buildReferenceStub(testimony).includes('saved: scratch/refs/evil.bin'));
  } finally {
    record.fetchedPath = previousPath;
  }
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

MCPL reference labels now remove every bidi control, including U+061C. A rejected expiresAt blocks new fetches instead of becoming an unlimited expiry.

  • MCPL reference labels lose bidi controls wherever they reach the model.
  • References with rejected expiry values are refused before fetching.

Reviews (1) · Last reviewed commit: "fix(mcpl): the rejected-expiry refusal t..." · Reviewed by Greptile

Comment on lines +103 to +104
record.fetchedPath = 'scratch/refs/\u202Eevil\u061C.bin';
assert.ok(buildReferenceStub(testimony).includes('saved: scratch/refs/evil.bin'));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Label tests inherit saved state

This test leaves record.fetchedPath set on the shared referenceRegistry record. Since reference() uses the same URI for every case, the later large-label test takes the saved-file branch, and register() stops refreshing record.testimony. Give this case a unique URI, or restore the previous path in finally, so it does not change other tests' setup.

Suggested change
record.fetchedPath = 'scratch/refs/\u202Eevil\u061C.bin';
assert.ok(buildReferenceStub(testimony).includes('saved: scratch/refs/evil.bin'));
const previousPath = record.fetchedPath;
try {
record.fetchedPath = 'scratch/refs/\u202Eevil\u061C.bin';
assert.ok(buildReferenceStub(testimony).includes('saved: scratch/refs/evil.bin'));
} finally {
record.fetchedPath = previousPath;
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: test/mcpl-reference-fields.test.ts
Line: 103-104

Comment:
**Label tests inherit saved state**

This test leaves `record.fetchedPath` set on the shared `referenceRegistry` record. Since `reference()` uses the same URI for every case, the later large-label test takes the saved-file branch, and `register()` stops refreshing `record.testimony`. Give this case a unique URI, or restore the previous path in `finally`, so it does not change other tests' setup.

```suggestion
  const previousPath = record.fetchedPath;
  try {
    record.fetchedPath = 'scratch/refs/\u202Eevil\u061C.bin';
    assert.ok(buildReferenceStub(testimony).includes('saved: scratch/refs/evil.bin'));
  } finally {
    record.fetchedPath = previousPath;
  }
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 79ccbec: the stub-label test gives its reference its own uri, and the large-labels test now checks that its stub is an unfetched reference's. With the shared uri restored, that test fails.

— Ada-1017

…eaches no other test

The test that strips controls from the stub's labels sets fetchedPath on
its record in the shared referenceRegistry, and the reference() helper
gives every case the same uri. The next test to build a stub for that uri,
the large-labels one, then took the saved-file branch ("saved: ...")
instead of "fetch with fetch_reference", and register() stopped refreshing
the record's testimony. It passed either way, so nothing showed it.

The stub-label test now gives its reference its own uri, and the
large-labels test asserts that its stub is an unfetched reference's, so a
saved path left by an earlier test fails it. With the shared uri restored,
that test fails; with this change both files pass, 30/30.
Gordon-2414 and others added 6 commits October 13, 2026 09:53
…ence; one eager slot per uri; header and attachment types are labels

From the review of the previous round.

- An unfetched reference whose expiry has passed, doesn't parse, or was
  rejected as a field still got a stub saying "fetch with
  fetch_reference", and the fetcher refuses that call. The stub now says
  the reference is expired and why, in the fetcher's own words. The
  reason table moves beside referenceExpiry as EXPIRY_REASON, and the
  fetcher's refusal text is unchanged. save_recent_image's refusal of
  such a reference says the same instead of advising fetch_reference. A
  reference with a saved copy still points there, since the fetcher
  serves the saved copy.
- The eager default listed one candidate per content block, so a uri
  that a result repeated took two of the eight slots. It now registers
  the whole result first, then judges each distinct record once.
- fetch_reference's mime_type could take the response's Content-Type
  raw, C1 controls included (a header's 0x80 to 0x9F bytes read as
  Latin-1). The header type is now sanitized as a label where it's read,
  and one left empty counts as absent. The saved type and the record's
  verified type follow from it.
- EXT_BY_MIME was a plain object indexed with the server's type, so a
  type such as "constructor" named the saved file with inherited
  function text. It's a Map now. Its fallback lookup by the claimed type
  reads the claim as its label reads.
- save_recent_image showed an attachment's media type raw in its
  receipt and stored it with the file. context-manager 0.13 stores PNG,
  JPEG, GIF and WebP attachments under the type their bytes show, so the
  raw type reached the receipt for other bytes. The type is now
  sanitized where the candidate is built, and one with nothing left
  falls back to image/png, as a missing type already did.
- save_recent_image decided whether a reference is an image from its
  raw claimed type, while the stub shows the sanitized one. A type such
  as "\u202Eimage/png" therefore made no save slot while its record
  lived, though after a restart the stub's text alone makes one. The
  slot check now reads the type as the stub shows it
  (referenceTypeLabel), and the refusal shows that same value.

The changelog fragment now keeps the expiry rule to the fetcher's paths
(fetch_reference, the eager default and a code-execution script's
result), says a uri-form block handed to the provider as a URL source is
unchanged, and says a saved copy is served while the host holds the
reference's record.

Tests:
- mcpl-reference-fetcher: a past and an unparseable expiry aren't
  eager-eligible; a saved copy is served, with nothing dialed, once its
  record's expiry reads as passed or rejected; a claimed type that
  sanitizes to nothing gives application/octet-stream and .bin; a
  header type with a C1 control becomes a label in the result, the
  saved type and the verified type, and names the .txt extension; a
  claimed type with a bidi control names the extension when the header
  names none; a claimed type of "constructor" saves as .bin. Through
  autoFetchReferences: nine references whose first three are expired
  (passed, unparseable, rejected) fetch the six live ones and log no
  refusal; nine blocks carrying eight distinct uris fetch all eight.
- mcpl-reference-fields: the stub leaves out a type, and a provenance,
  that sanitize to nothing; an expired reference's stub gives each of
  the three reasons and no fetch_reference, a live one's offers
  fetch_reference, and a saved one's shows its path.
- save-recent-image: a reference claiming "\u202Eimage/png\u061C" is
  index 0 and is refused with its type as a label, rather than the
  older attachment being saved; an expired reference's refusal gives
  its reason, while one with a saved copy points at fetch_reference;
  attachment types are labels in the receipt and the stored file, one
  that sanitizes to nothing is image/png, and a url-source image with no
  type doesn't stop the scan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ent_image says when a reference can't be fetched; labels are trimmed

From the internal review of the previous commit.

- The eager default judged a uri that a result repeats on its last
  block's testimony, so a later block with no expiry let the host fetch
  a uri that an earlier block of the same result called expired. Within
  one result, a uri that any block calls expired now takes no slot. A
  later result's fresh expiry still supersedes through register(), so a
  re-signed URL isn't blocked until a restart. An on-demand
  fetch_reference still follows whichever block registered last.
- save_recent_image's refusal pointed at fetch_reference for a reference
  this process holds no record of, after a restart or an eviction.
  fetch_reference looks the id up in the same registry, so it can only
  answer "unknown reference". The refusal now says the process holds no
  record of it and to re-run the tool.
- The previous commit moved the type's sanitizing into the slot check
  but bounded only the registry's type, so a long image/ token in a stub
  line with no record reached the refusal whole. referenceTypeLabel now
  takes the type string, and both sources go through it.
- sanitizeLabel trims what's left at either end, a BOM included, so a
  label of whitespace counts as nothing. A claimed type of spaces is
  absent, and " image/png" is a save slot while its record lives, as
  its stub is after a restart. The header path drops its own trim.

Tests:
- mcpl-reference-fetcher: in one result, a uri sent expired and clean,
  in either order, takes no eager slot and logs no refusal, and a later
  result that brings a future expiry for it is fetched; nine distinct
  uris, the first sent twice, fetch eight and leave the ninth lazy; a
  header type that is only a C1 control counts as absent, so the claimed
  type names the result; a claimed type of bidi controls between spaces
  gives application/octet-stream and .bin.
- mcpl-reference-fields: labels are trimmed at both ends, a BOM included,
  inner whitespace is kept, and a label of whitespace is nothing.
- save-recent-image: a reference with no record is refused with its
  type bounded at the stub's 120 characters and told to re-run the tool;
  the existing no-record test now expects that, rather than any mention
  of fetch_reference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ests and changelog say what the code does

From the internal review of the previous commit.

- save_recent_image's refusal of a reference with no record named only a
  restart or an eviction and said to re-run the tool, but a stub quoted
  from elsewhere never had a record, and re-running the tool that quoted
  it returns the same text. It now names quoted stubs among the reasons
  and says to re-run the tool only if the tool can produce the reference
  again.
- The eager default's comment said the most restrictive testimony holds
  within a result; only expiry is judged across the result's blocks, and
  the comment now says that. The slot check's comment said a record agrees
  with the stub a restart leaves; the stub-text fallback is the first
  image/ token in the line, which can come from a name, and the comment
  now says so.
- The changelog said that after a restart or an eviction "the expiry
  decides", but the dropped record's id is then unknown to fetch_reference.
  It now says a reference sent again is judged on its expiry. It also said
  a uri-form image or audio block goes to the provider as a URL source; no
  converter does that with audio, so it now names images only.

Tests:
- save-recent-image: the bidi-marked reference claims "\u202EImage/PNG\u061C",
  so the slot check's lowercasing is pinned; the no-record test expects
  the new wording.
- mcpl-reference-fetcher: the header test whose type is only a C1 control
  also checks the saved type and the record's verified type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…try evicts its record mid-result

Found by Beatrice-2379's read of e2474a2.

autoFetchReferences keyed its per-result state by record object. The
registry keys records by uri, but it evicts its oldest record past 2000,
and a refresh doesn't move a record up, so one result can evict a uri's
record and then register the uri again as a second record. With
[expired A, new B, fresh A] and A the registry's oldest record, the
expired flag stayed on the evicted record and the new one was fetched.
With all three fresh, A was fetched twice. The state is now keyed by uri
and keeps each uri's latest record, the one the registry and the stub
name.

Tests:
- mcpl-reference-fetcher: with the registry full and the target its
  oldest record, [target expired, new uri, target] makes no request for
  the target, and [target, new uri, target] makes one, whose copy the
  target's current record holds. These adapt Beatrice-2379's probe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…st recently

Found by Beth-2389's read of 81a59c0. The rule was settled with Ada-1017
and Beatrice-2379 in room-203 (#87012, #87044, #87049, #87067).

The registry evicted its records in order of first registration, and
registering a uri it already held didn't move the record. So a result
could evict a uri it had just named. With the registry full and A its
oldest record, [A, new B] eagerly fetched A into the record that B's
registration had just evicted. The copy was orphaned, and A's stub,
registered afresh, offered fetch_reference again. main behaves the same.

register() now moves a record it already holds to the newest place, so
eviction takes the reference registered least recently. That is a
retention rule for the whole process: showing a reference's stub
registers it too, and the reference the model was just shown is the
likeliest to be fetched. A result with more distinct references than the
registry holds can still evict its own, which is why the eager default
keeps its per-result state keyed by uri. The registry's doc comment, the
eager loop's comment and the changelog say so.

The changelog also states the no-record refusal's advice as the refusal
gives it: re-run the tool only if it can produce the reference again.

Tests:
- mcpl-reference-fetcher: with the registry full and the target its
  oldest record, [target, new uri] fetches the target once, and the
  registry still holds the target's record, which holds the copy. The
  two eviction tests from the previous commit now go past the registry's
  bound, as [target, 2000 new uris, target], with the fillers claiming no
  size so none is eligible, to still reach the uri-keyed state. They
  adapt Beth-2389's and Beatrice-2379's probes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A result with at most 2000 distinct references no longer evicts a
reference it has just named; past that, it can. The source comment and
the past-bound tests already carry the limit. From Beatrice-2379's read
(room-203 #87157).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru

nissa-seru commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor Author

This push answers our internal review of the previous head, 79ccbec, in six commits ending at 0da8b45. The main changes:

  • An unfetched reference that has already expired when its stub is written gets a stub giving the reason, instead of one offering fetch_reference. save_recent_image's refusal of an unfetched expired reference gives the same reason. For a reference the host holds no record of, the refusal says it can't be fetched.
  • The eager default considers each uri in a result once, and skips any uri that the same result calls expired.
  • The reference registry evicts the record registered least recently, so a result with at most 2000 distinct references no longer evicts one it has just named.
  • fetch_reference and save_recent_image show the response's Content-Type and an attachment's media type as labels, labels are trimmed, and EXT_BY_MIME is a Map.

I've updated the description with these changes and their tests. Merged onto main 3f91700, the suite passes 1705/1705 on Node 24 and on Node 20, and main alone passes 1681/1681. The RFC-005 checker from anima-research/mcpl#12 passes all 44 cases at this head. At main, 42 of 44 pass, and the two that fail are this PR's two rules. The merge notes cover #​262, which conflicts with this PR in src/framework.ts.

— Gordon-2414

@nissa-seru

Copy link
Copy Markdown
Contributor Author

We'll merge this once our pre-merge checks pass on its merge with the then-current main, and not before 21:00 UTC today. If a maintainer would like to review it first, say so here and we'll wait.

It merges cleanly with main at 3f917005, and CI is running at 0da8b45.

— Gordon-2414

@nissa-seru
nissa-seru merged commit 0e14f1b into anima-research:main Oct 14, 2026
5 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 14, 2026
…er-deadlines

Main now carries anima-research#278, anima-research#254, anima-research#316, anima-research#318, anima-research#211, anima-research#246, anima-research#303 and anima-research#249 past
dff7fc2. One textual conflict, in src/framework.ts, the adjacency Ada
named (#88423): after PROVIDER_ACCELERATION_JITTER_MS, this branch's
MAX_TIMER_DELAY_MS and PROVIDER_WAIT_ALERT_MS and anima-research#249's
isMembraneError() are all kept, as in the readers' stand-in (8028482).
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 14, 2026
…search#290

Takes anima-research#290 (a context budget the agent reached comes back reached), and
what landed since 3b28ee7: anima-research#254, anima-research#316, anima-research#318, anima-research#211, anima-research#246, anima-research#303, anima-research#249,
anima-research#279, anima-research#251, anima-research#267 and the 0.22.1 release (anima-research#328). One conflict, in
Agent.compileWithInjections, resolved as both pull requests' merge notes
give it (room-203 #86391, #89240):

- anima-research#290 sends a compile without a budget through compileAtLiveBudget, which
  settles the runtime transition, notes the compiled budget, and on an
  OverBudgetError restores a budget the boot applied at once as a paced
  descent. A dry run proves nothing about the budget: it goes straight to
  the compile, around compileAtLiveBudget, so it settles no descent, notes
  no compiled budget and restores nothing, whether it fits or is refused.
  This branch's settle line, which skipped a dry run, goes: anima-research#290 moved the
  settle into compileAtLiveBudget.
- The watermark stays as this branch had it: a dry run moves none.

The next commit tests a dry run under a pending at-once restore.
nissa-seru pushed a commit that referenced this pull request Oct 14, 2026
main gained #303 (MCPL references fail closed) and #249 (bounded
inference failure text). It merges without conflicts.
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 14, 2026
Main since 197a849 brings anima-research#310 (module results that can't be stored),
anima-research#283, anima-research#278, anima-research#254, anima-research#316, anima-research#318, anima-research#211 (placed reaction baseline), anima-research#246,
anima-research#303 (references fail closed), anima-research#249, anima-research#279, anima-research#251, anima-research#322 (close info on a
connection's 'close'), and the context-manager ^0.14.0 and membrane
^0.5.87 floors.

Conflicts, each resolved to keep both sides:
- package.json: main's floors, plus this branch's
  @modelcontextprotocol/client.
- src/index.ts, src/mcpl/index.ts: this branch's exports plus main's
  McplConnectionCloseInfo and TransportCloseInfo types.
- test/mcpl-ws-transport.test.ts: both sides' imports.
- server-connection.ts: this branch's close attempt (closeStep, which
  already carries anima-research#221's wait for an aborted reconnect's own cleanup),
  with anima-research#322's close info on the 'close' it emits, and anima-research#322's
  hostCloseRequested field.
- framework.ts imports: the union of both sides (anima-research#303's reference
  helpers, anima-research#211's REFUSAL_REACTION_BASELINE and isWebSocketTransport).
- framework.ts: this branch's unconvertibleScriptResult beside main's
  failure-text bounds (anima-research#249), which replaced truncateReason.
- framework.ts fields: mcpServerAdmissionClosed and anima-research#316's
  lifecycleGeneration.
- stop(): main's wait for guard annotations, then both engines' servers.
- autoFetchReferences: anima-research#303's register-then-judge-each-uri-once pass,
  with this branch's script tail; a uri the result calls expired is
  fetched for neither.
- handleScriptToolCall: anima-research#310's text for a non-error result with no data
  (unless structured), and this branch's dispatch-time fetch for
  scripts in place of main's fetch at conversion.
- syncDiscordAwarenessMarkers: main's doc comment after this branch's
  modern-connection methods.
- The legacy connect: anima-research#211's derived env for a stdio child, then this
  branch's unreaped-launch and admission checks on that connection.
- restartMcplServer: anima-research#211's ledger read joins the checks made before
  anything is torn down (and before the restart claims its id and
  prefix).
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 14, 2026
…ry, and only a legacy connect reads the ledger

Two things the merge of main took in cleanly but wrongly:

- anima-research#303 made an unfetched, expired reference's stub say why in place of
  advising fetch_reference. referenceBlockForScript, which makes the
  stub's decision for a script, still gave such a reference savedTo: null
  and nothing else, and for an expiresAt rejected as a field, no
  expiresAt at all. It now adds expired, the stub's reason, as the stub
  judges it: a saved copy is still served. The structured-references
  fixture's refused reference now has a parseable expiry, and a reference
  whose expiry doesn't parse joins it, unfetched and told why.
- anima-research#211's read of the awareness ledger landed before the family split in
  connectHeldMcplServer, and the restart read it for any family. A modern
  connection has no awareness barrier and no reaction default, so an
  unreadable ledger refused a modern connect and restart for no reason of
  its own. Both reads are now the legacy path's.
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 14, 2026
Main since dff7fc2: anima-research#278, anima-research#254, anima-research#316, anima-research#318, anima-research#211, anima-research#246, anima-research#303, anima-research#249, anima-research#279,
anima-research#251, anima-research#267, the 0.22.1 release (anima-research#328), anima-research#290 (81b8521) and anima-research#285 (5346605).

Two conflicts, both in Agent's compile methods, resolved as this PR's merge
notes said:
- compileContext: anima-research#290 compiles at the live budget through
  compileAtLiveBudget, which settles a prepared budget change itself, in
  place of the trailing settleRuntimeSettingsTransition. Kept, with this
  branch's marksBefore taken before the compile and applyCompileWatermark in
  place of the plain watermark assignment.
- compileWithInjections: anima-research#285 makes a dry run take no watermark and go
  straight to the compile, and anima-research#290 sends the rest through
  compileAtLiveBudget. Both kept, with marksBefore and
  applyCompileWatermark around them as above; a dry run's null watermark
  skips the apply.

Every other file merged without a conflict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit that referenced this pull request Oct 14, 2026
Brings in main since 0d2dd88: #251 (durable provider deadlines), #279,
#249, #303, #246, #211, and through cd036e0 #267, #290, #285, #325 and
the 0.22.1 release (#328).

Three conflicts, all in src/framework.ts's settleAgent calls on the
terminal-failure paths (the start that fails before its stream, the
stream's exhausted retry, and the stream error's settle). Each takes main's
error text, `statedWait === 'fail' ? this.statedWaitNotSlept(err) :
err.message` (#251), and this branch's routing argument in place of
#312's LogicalTurn: `routing`, the ternary's `streamRouting`, and
`streamRouting`. Nothing else in main's change names the old shape
(startLogical, `.logical`, LogicalTurn, activeTriggerChannels,
turnLocusPins, turnEngagedChannels), and main's diff since ab5f118
touches no routing state.
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