Repository navigation
feat(agent): thinkingBinding.dropBlock asks the provider to drop thinking blocks that a fold invalidated - #768
Conversation
…king a fold invalidated An agent's signed thinking is bound to the conversation before it, and a fold that rewrites that conversation leaves every later block bound to a different one. An account that enforces the binding refuses such a request with a 400. thinkingBinding.dropBlock, off by default, sends thinking.block_binding.prefix_mismatch_behavior "drop_block" (membrane 0.6.0's thinking.blockBinding, with its beta, through the Anthropic and Bedrock adapters), so the provider drops the blocks that no longer match and answers. requestSettings() adds it, so a framework turn and the direct API both carry it. An agent with no thinking config of its own sends one holding only the control, which membrane carries to a model whose thinking is always on and to no other. Each call's report (membrane's RoundReport.thinking) goes to its inference:usage trace as `thinking` when it names a block, and a call that dropped blocks isn't a calibration sample: the provider doesn't bill a block it dropped, and the context strategy still counts it. assertAgentSwitches refuses a thinkingBinding that isn't an object, null or false, and a dropBlock that isn't true or false. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hinking; say where the report is read The context strategy takes the first call it's given after a compile as that compile's one calibration sample, and every later call of the stream carries what the stream appended. Skipping only the call that dropped blocks let the next, grown call of the same compile take that sample. Once a call of a stream reports dropped thinking, none of the stream's later calls is given either (a local in driveStream; a stream is one compile). Texts: - the report is read on a framework turn; the direct API carries the control and reads none, since it traces no usage and feeds no calibration; - an account that doesn't enforce the binding decides for itself, and one that would show a mismatched block is asked to drop it; - a thinking config the host sends itself (request.extra, or thinking in providerParams) replaces membrane's, control included; - a model whose thinking is always on gets a thinking config only to carry the control, display included, so its display takes effect only with the setting; AgentConfig.thinking's doc says such a model thinks whatever enabled says; - membrane's warning that the Mythos family has refused thinking configs with a 400, and that Bedrock's acceptance of the beta is untried; - inference:usage's thinking is the call's report, while tokenUsage is the stream's running total. Tests: the request test builds each config fresh, compares against literals, checks the given config is left as it was, and covers thinking null and false; the stream test runs four calls (no report, a shown block, a dropped block, a report naming nothing); agent-cache-ttl's direct-API tests set the control. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Automated review
Recommendation: merge and fast-follow. 2 medium severity findings can be fixed after the merge.
Reviewed commit e6a5922 with claude-opus-5-5, by reading the code. It did not run the code or look at the checks.
Does this change belong in the project?
It fits the project. This fits. It adds an opt-in, off-by-default agent setting that passes through a membrane control so that the requests the agent framework sends stop being refused after a context fold.
5 reasons with sources
It follows the existing patterns for agent settings, request settings and trace events.
- It fixes a failure in what the framework already does, which is building context, sending it through membrane and keeping signed thinking blocks. The README describes the framework as handling context compilation and inference through membrane. AgentConfig.thinking already says signed thinking blocks are kept through Chronicle. Accounts that enforce the binding answer with a 400 on every turn after a fold, so the framework's own fold-plus-thinking path breaks without this change. (README.md (Overview); src/types/agent.ts (
thinkingdoc)) - The setting lives where other agent switches live, and it is checked by the same validator.
thinkingBindingis added to AgentConfig and checked by assertAgentSwitches using holdsSettings and assertBooleanSetting, the same way asthinkingandrefusalHandling. It reaches requests only through requestSettings(), which that function's doc names as the single source for both the framework-turn and direct-API paths. (src/agent.ts (assertAgentSwitches, requestSettings)) - It uses a control membrane already exposes and adds no new dependency or adapter logic. package.json already depends on @animalabs/membrane ^0.7.1, which is newer than the 0.6.0 the description cites. The provider-specific wire format stays in membrane's adapters. That matches the layering CONTRIBUTING describes, where this repo sits on membrane. (package.json; CONTRIBUTING.md (How changes land))
- Reporting goes through the existing
inference:usagetrace event rather than a new channel. An optionalthinkingfield is added to the existing event, and it appears only when the report names a block. The calibration gating for the context strategy is a narrow change to the existing reportRealInputTokens call. The finding that this gating can leave calibration stuck is a defect for code review, not a question of scope. (src/types/trace.ts) - The change is one coherent feature and comes with tests and a changelog fragment, as CONTRIBUTING asks. It adds changelog.d/thinking-binding-drop-block.added.md in the documented format, a new test file, and updates to the parity test. Because the setting is off by default, an agent that doesn't set it sends exactly what it sent before. (CONTRIBUTING.md (What a PR should contain; Changelog))
Findings
2 findings of medium or high severity, see the inline comments:
- Medium severity. Dropped-block gating can freeze the context strategy's calibration for as long as folded-over thinking stays in context.
- Medium severity, test coverage. No test that calibration resumes on the next stream, or that a stream whose first call drops blocks gives no sample.
1 more finding, less severe
- Low severity, test coverage,
test/thinking-binding.test.ts:85-115. The display behaviour change on always-on models is documented but not tested. The docs in src/types/agent.ts (lines 167-170 and 197-200) and the changelog say that on a Fable or Mythos model, the agent'sthinking.displaynow reaches the provider only withdropBlock, because the control is carried in an adaptive config. This is a user-visible change: an agent withdisplay: 'summarized'starts getting thinking text back. The wire test checks Fable only with nothinkingconfig. No test sendsthinking: { enabled: false, display: 'summarized' }to a Fable model to confirm thatdisplaygoes out alongsideblock_binding, or that it does not go out without the setting.
| const thinkingReport = event.round?.thinking; | ||
| const thinkingDropped = (thinkingReport?.dropped.length ?? 0) > 0; | ||
| if (thinkingDropped) thinkingDroppedInStream = true; | ||
| try { | ||
| strat?.reportRealInputTokens?.( | ||
| realTotal, | ||
| compiledRequest ? { system: compiledRequest.system, tools: compiledRequest.tools } : undefined, | ||
| ); | ||
| if (!thinkingDroppedInStream) { | ||
| strat?.reportRealInputTokens?.( | ||
| realTotal, | ||
| compiledRequest ? { system: compiledRequest.system, tools: compiledRequest.tools } : undefined, | ||
| ); | ||
| } |
There was a problem hiding this comment.
Medium severity. Dropped-block gating can freeze the context strategy's calibration for as long as folded-over thinking stays in context.
A fold rewrites the earlier conversation, so every signed block after the fold point stays bound to a prefix the request no longer holds. With drop_block the provider drops those blocks on every later request until a later fold removes them. The new code withholds reportRealInputTokens from any stream whose call reports dropped.length > 0, so for an agent that folds regularly, the first call of almost every stream reports drops and no compile ever gives a sample. The comment at 20506-20510 admits this for a model switch, but folds are the main reason for the feature, so the case is ordinary, not an edge case. The estimator then keeps an old multiplier indefinitely and never learns the new tokenizer or overhead. A fix would correct the sample by the dropped blocks' estimated size instead of discarding it. Failing that, the stall should be documented and measured, for example with a trace or counter for skipped samples.
| const dir = mkdtempSync(join(tmpdir(), 'af-thinking-binding-')); | ||
| const membrane = new MockMembrane(); | ||
| const framework = await AgentFramework.create({ | ||
| storePath: join(dir, 'store.chronicle'), membrane: membrane.asMembrane(), agents: [], | ||
| modules: [new EchoTools()], syncIntervalMs: 0, | ||
| }); | ||
| const traces: TraceEvent[] = []; | ||
| framework.onTrace((event) => traces.push(event)); | ||
| framework.start(); | ||
| try { | ||
| const dropped = { reason: 'prefix_binding_mismatch', path: 'messages.1.content.0', message: 1 }; | ||
| const shown = { reason: 'prefix_binding_mismatch', path: 'messages.3.content.0', message: 3 }; | ||
| // Four calls of one stream: no report, a block shown though it failed | ||
| // the check, a block dropped, and a report that names nothing. | ||
| membrane.pushResponse(withRound(createMockResponse([{ type: 'tool_use', id: 'c1', name: 'test--echo', input: {} }], 'tool_use'), | ||
| { inputTokens: 100, outputTokens: 10, cacheCreationTokens: 0, cacheReadTokens: 400 })); | ||
| membrane.pushResponse(withRound(createMockResponse([{ type: 'tool_use', id: 'c2', name: 'test--echo', input: {} }], 'tool_use'), | ||
| { inputTokens: 130, outputTokens: 15, cacheCreationTokens: 0, cacheReadTokens: 900 }, { dropped: [], mismatchAllowed: [shown] })); | ||
| membrane.pushResponse(withRound(createMockResponse([{ type: 'tool_use', id: 'c3', name: 'test--echo', input: {} }], 'tool_use'), | ||
| { inputTokens: 150, outputTokens: 20, cacheCreationTokens: 60, cacheReadTokens: 900 }, { dropped: [dropped], mismatchAllowed: [] })); | ||
| membrane.pushResponse(withRound(createMockResponse([{ type: 'text', text: 'Done.' }]), | ||
| { inputTokens: 170, outputTokens: 25, cacheCreationTokens: 60, cacheReadTokens: 1400 }, { dropped: [], mismatchAllowed: [] })); | ||
|
|
||
| const { agent, contextManager } = await framework.createEphemeralAgent({ | ||
| name: 'worker', model: 'claude-opus-5-5', systemPrompt: 'Do the task.', allowedTools: 'all', | ||
| proseRouting: 'disabled', thinkingBinding: { dropBlock: true }, | ||
| }); | ||
| const calibrated: number[] = []; | ||
| const strategy = contextManager.getStrategy() as unknown as { reportRealInputTokens?: (n: number) => void }; | ||
| strategy.reportRealInputTokens = (n) => { calibrated.push(n); }; | ||
| contextManager.addMessage('user', [{ type: 'text', text: 'Run once.' }]); | ||
| await framework.runEphemeralToCompletion(agent, contextManager); | ||
|
|
||
| const usage = traces.filter((event): event is Extract<TraceEvent, { type: 'inference:usage' }> => event.type === 'inference:usage'); | ||
| assert.equal(usage.length, 4); | ||
| assert.equal('thinking' in usage[0]!, false, 'a call with no report carries no thinking field'); | ||
| assert.deepEqual(usage[1]!.thinking, { dropped: [], mismatchAllowed: [shown] }, | ||
| 'a block shown to the model though it failed the check is reported too'); | ||
| assert.deepEqual(usage[2]!.thinking, { dropped: [dropped], mismatchAllowed: [] }); | ||
| assert.equal('thinking' in usage[3]!, false, 'a call whose report names nothing carries no thinking field'); | ||
| assert.deepEqual(calibrated, [100 + 0 + 400, 30 + 0 + 500], | ||
| 'each call before the drop is given, as its own share of the stream\'s usage, a shown block being billed; ' + | ||
| 'the call that dropped one and the call after it are not, since the strategy would take the first call given after its compile'); | ||
| } finally { | ||
| await framework.stop(); | ||
| rmSync(dir, { recursive: true, force: true }); | ||
| } | ||
| }); | ||
|
|
||
| it('refuses a setting that is not true or false, and a thinkingBinding other than an object, null or false', async () => { |
There was a problem hiding this comment.
Medium severity, test coverage. No test that calibration resumes on the next stream, or that a stream whose first call drops blocks gives no sample.
The calibration test runs a single stream and only drops blocks on its third call. Nothing checks that thinkingDroppedInStream is scoped to one stream: if the flag were hoisted to the agent or framework, calibration would stop for good and this test would still pass. The common case after a fold, where the very first call of a turn drops blocks and the compile gives no sample, is also untested. Add a second turn or stream with no drops and assert that it is calibrated again, and add a case where the first call drops.
|
#770, stacked on this pull request's head, adds the two tests (the skip is one stream's, and a Fable model's — Effie-2756 |
…qualified channels meet the registry, tune-out and the reply anchor What main brings since e4d1e46, the sixth catch-up's base: 0.25.0 and 0.26.0, and anima-research#709, anima-research#710, anima-research#719, anima-research#722, anima-research#724, anima-research#728, anima-research#727, anima-research#743, anima-research#744, anima-research#755, anima-research#759, anima-research#761, anima-research#763, anima-research#767, anima-research#707, anima-research#740, anima-research#760, anima-research#726, anima-research#371, anima-research#452, anima-research#768, anima-research#465, anima-research#770, anima-research#771, anima-research#723, anima-research#777, anima-research#776, anima-research#773, anima-research#775, anima-research#778, anima-research#774, anima-research#779 and anima-research#781. Most of the conflicts are where anima-research#710 met the branch: in main a channel the framework acts on is a ChannelRef, { serverId, channelId }, since two MCPL servers can register one id. Conflicts in 14 files (61 hunks), resolved by keeping both sides' intent; where the two had different designs for one thing, the branch's stays, moved onto main's types: - src/framework.ts: main's conversationAgentHomes map stays gone. A fork's home is the registry record's homeChannel, which homeOf reads wherever main read the map (isForkBound, mayTakeGateRoute, canCloseChannels, holdsChannelTraffic, agentForPrimary, the drafts resend, the channel tools' guard, the registry's homeChannelResolver, stop()'s unbinding, releaseAgentRuntime's). The record still keeps a bare id, read as main reads a wake that names its channel without its server (channelNamed), or, where it names no one registered channel, as a home on no server ({ serverId: '' }), which a path reading the pair finds unregistered and one reading the name finds no channel or two for. main's dropContinuations goes into releaseAgentRuntime, so every way a live non-ephemeral agent ends drops a continuation of its turn, and beside the registry's end of an ephemeral run. Arrival carries main's channel ref and the branch's addressed; the addressed-elsewhere notice names channels with main's channelShown and keys its once-per-channel set by destinationKey. isAddressedMessage stays tag-only, with main's ambient marks. publishInputWithTurnLocus gives the locus with its server, and only to a call made inside the turn (callingTurnOf); outside it, a conversation fork's call takes its home, as its dispatched calls do, and anyone else's the registry's default. The tune_out handler keeps the branch's shape (serverId, scope, check, peek) and resolves through ChannelRegistry.resolveChannel, now resolveToolChannelEntry with the calling turn's own channels, check's peek included; its answers name the channel with nameShown. holdForProviderWait holds nothing for an agent a listener of the failure it reports ended (stopAgent), where main's report-before-hold (anima-research#774) meets the branch's stopAgent at a conversation agent's hold after its compile. handleSubconsciousTool gets main's own channels, then the branch's caller. - src/tune-out/coordinator.ts: main's shown() names beside the branch's reader helpers; deliver_summary, cancel_tuneout and speak_in_channel resolve as main's do, for the subconscious and a reader fork alike; cancel_tuneout keeps the branch's serverId and all-except fallback, and a name that picks no channel (one two servers share, or a turn's own channel ownChannelFor refuses) doesn't keep a call naming the scope from ending it; findByChannel goes, as on main. focusChannel() returns a ref. - src/mcpl/channel-registry.ts: markAccepted records no anchor (admission does), and handleIncoming's arrival count of removals, which main's guard there read, goes with it; noteDelivered takes the channel with its server and sets nothing for a channel its server no longer registers, read at admission where main's guard compared removal counts at arrival and acceptance (a message admitted after its server registers the channel again sets the anchor here). buildChannelContext is main's, per server, with the branch's sharedFallback; incomingFor reads the anchor itself, not the speech fallback. globalFallbackFor, sharedFallback, fallbackLocus and focusChannelResolver are on refs. - src/index.ts: main's DefaultErrorPolicy beside the branch's exports. - Tests: the branch's registry stubs take main's shapes; main's appendInTwoParts in batched-wake-locus's rig. Beyond the conflicts: the all-except announcement goes to the excepted channel on its server (publishForAgent refuses a shared bare id since anima-research#710), and tune_out's all-except answer names its channel with nameShown; the tune_out schema's serverId description says channelId takes serverId:channelId too; main's refusals for a tuned-out channel no name picks alone give the tune_out cancel by id and server, since this branch's tune_out takes a serverId; a stale comment naming conversationAgentHomes in channel-registry.ts names the registry; docs/dendrite.md says where a publish naming no channel goes outside the turn, and no longer lists ChannelRef as unbuilt. Test adaptations: main's tests that set conversationAgentHomes give the fork its home through the registry. main's tests written to the latest-inbound rule run under it (reply-server-routes: speech LEGACY; shared-channel-id's registry fixture: latestInboundLocus, and noteDelivered as admission records). Two reply-server-routes tests of an in-turn publish naming no channel now check the branch's rule, the turn's locus through its server. tune_out's shared-channel-id tests wait for the handler's answer, and its shared-id refusal offers serverId. all-except- unit's stub registry has nameShown; present-while-acting and deferred-window-only build routing state in main's shapes. main's marker and removed-server anchor tests record the anchor at admission (noteDelivered), where this branch records it. At this commit alone, the tests where a fork's home has to name its server fail (two servers' channels with one id, or a home the registry doesn't register at that moment); the pull request lists them. The next commit stores the home with its server. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@antra-tess Flagging for your awareness this PR, which made the following changes (in brief):
— Lottie-2661 |
This adds
thinkingBinding.dropBlockto an agent's config. Set, it asks the provider to drop a signed thinking block whose earlier conversation has changed, as a fold changes it, rather than refuse the request. On a framework turn, each call's report of what the provider dropped goes to theinference:usagetrace, and neither a call that dropped blocks nor its stream's later calls feed the context strategy's calibration. It's off by default, and an agent without it sends nothing new.Problem
A thinking block's signature binds it to the conversation before it. When the context manager folds, it rewrites that earlier conversation, so every block after the change is bound to a conversation the request no longer holds. An account that enforces the binding answers such a request with a 400, "Invalid
signatureinthinkingblock. The block is bound to a different conversation." That happens on every turn after the agent's first fold (anima-research/context-manager#155).The provider's control for it is
thinking.block_binding.prefix_mismatch_behavior: "drop_block", with thethinking-binding-controls-2026-08-01beta. The provider then drops the blocks that no longer match, doesn't bill them, and answers, keeping every block that still matches. It reports what it dropped ininput_transformations. membrane 0.6.0 carries the control asconfig.thinking.blockBindingthrough its Anthropic and Bedrock adapters, and reports each round's entries asRoundReport.thinking(antra-tess/membrane#108). Onmain, agent-framework never sets it.Accounts differ. Four accounts tried on 2026-10-18 got neither the beta nor the control, which is what agent-framework sends today. On Opus 5.5 all four, and on Fable 5.1 the two that tried it, dropped those blocks themselves, without saying so, and each billed the same input as the same request under
drop_block."error"answered 400 on all four. anima-research/context-manager#155 reports the 400 on accounts created on or after 2026-08-31. An account can also show a block that failed the check to the model, which membrane reports asmismatchAllowed; none of the four did.Changes
AgentConfig.thinkingBinding.dropBlock, a boolean, off by default (src/types/agent.ts:179).requestSettings()addsblockBinding: { prefixMismatchBehavior: 'drop_block' }to the thinking config the agent sends (src/agent.ts:1546). A framework turn and the direct API both take their config from there. An agent whose thinking config is left out,nullorfalsesends{ enabled: false, blockBinding }. membrane carries that to a model whose thinking is always on (Fable, Mythos) as an adaptive config, and sends nothing from it to any other model, since thinking is off there.RoundReport.thinkingnames a block gets it in itsinference:usagetrace asthinking: { dropped, mismatchAllowed }(src/framework.ts:20548,src/types/trace.ts:207). That covers blocks the provider dropped, and blocks it showed the model though they failed the check. The direct API carries the control but reads no report, since it traces no usage and feeds no calibration.reportRealInputTokens(src/framework.ts:18831,:20515). The provider doesn't bill a block it dropped, while the strategy still counts it, so such a call's ratio would pull the calibration low. The strategy takes the first call it's given after a compile as that compile's one sample, and a stream's later calls carry what the stream appended, so skipping only the call that dropped blocks would hand the sample to a grown request.assertAgentSwitchesrefuses athinkingBindingthat isn't an object,nullorfalse, and adropBlockthat isn'ttrueorfalse(src/agent.ts:128), withnullcounting as left out, as for the other switches.thinking's anddisplay's on models whose thinking is always on, the trace field's doc, andchangelog.d/thinking-binding-drop-block.added.md.Who needs to act
thinkingBinding.dropBlockfor agents whose route and model take it.Membraneand knows the route.fableormythos. One behind another id, such as a router's alias, carries the control only while the agent'sthinking.enabledis on.thinking.display. So an agent there withdisplay: 'summarized'gets its thinking text back only with the setting. membrane 0.5.87 sent such a model no thinking config either.request.extraor asthinkinginproviderParams, replaces the one membrane builds, control included: the Anthropic adapter appliesextraover its params, and membrane's doc forblockBindingsays the same ofproviderParams. connectome-host's logging adapter does so for an agent with reasoning on (withReasoning,src/logging-adapter.ts). Such a host carriesblock_bindingin its own config for the control to reach the provider. The provider's report on such a call is traced, and kept out of calibration, the same way, whoever asked for the control.create()'ssubconscious) inherits the setting from the agent it serves, with the rest of that agent's inference settings,thinkingincluded (src/framework.ts:11821-11842). It does so even whensubconscious.modelnames another model, so that model should take the control too.What each part is for
"error"would turn an account's silent drop into a 400, which no agent asks for.thinking, sincethinkingrequiresenabled, and a model whose thinking is always on takes the control whateverenabledsays.{ enabled: false }config for an agent with none lets such a model carry the control. For any other model membrane sends nothing from it.thinkingDropBlockis private to the agent, read only by its request settings. A host sets the config field, and nothing outside the agent needs it back.model_binding_mismatch), they drop on every call until they leave the context, and the calibration takes up the new model's tokenizer only after that."false"doesn't turn it on.Membranefrom the host, whose adapter isn't exposed, so it can't see the route at config load. The host can.maxStreamTokensrestart (agent.lastStreamInputTokens,src/framework.ts:12246), which checks the stream's accumulated fresh input, and the in-stream projection (agent.lastStreamRealInputTokens,src/framework.ts:20470), which checks the next round againstphysicalWindowTokens. A dropped block isn't billed, so the restart comes later by its size, and the projection runs low by it whenever the provider counts it toward the window, which hasn't been measured. On the accounts above,mainalready bills that way. With the setting, the two differ only on an account that enforces, where they stand in for a 400.Tests
At this head,
e6a59222, onmaine66bdae1, on Linux with Node 24.22.1, from a clean copy of each commit, installed as CI does: membrane 0.7.1 and context-manager 0.20.0, one copy of membrane.test/thinking-binding.test.ts, passes, 4 of 4. It pins:nullorfalse, and with the agent's own config left as it was;block_bindingwith the beta for a model with thinking on, an adaptive config for Fable 5.1 with no thinking config, and nothing for another model with thinking off or without the setting;inference:usagetrace carries what its report names, and calibration takes the two calls before the drop;create()andnew Agent(), withnullandfalsecounting as left out.agent-cache-ttl's direct-API tests now set the control, so the direct API's request carries it, and carries what a framework turn's does.config-switches8,agent-cache-ttl7,end-turn-usage41,activation-request-sanitize5,assistant-persist-refusal5,kv-unified-wiring3,unconvertible-tool-result11,unsent-compiles5 andtool-result-guard150.src/agent.tsandsrc/framework.tsfrommainate66bdae1and this head's types and tests, all four new tests fail. With them from the first commit,19c87e4f, the four-call stream test fails, on the calibration taken after the drop.nullthinking config passed on as one;agent-cache-ttl);main:mainpasses{ system, tools }with each calibration sample (fix(calibration): the sample carries the request's system prompt and tools (context-manager #323) #763, for Calibration learns tool-schema and system-prompt overhead as a per-token ratio; at 1.00 it rejects every sample until one dips under 1.8, then climbs to the clamp context-manager#323), and that call sits inside this change's skip. Each, taken out alone, fails its own test: the skip fails the four-call stream test, and the second argument failscalibration-request-overhead's.dropBlockis read as=== trueafter the refusal, which any non-boolean meets first, so no test can tell that read from a truthy one.test/helpers/mock-membrane.tspasses a response's test-onlyroundinto its usage event, as membrane's yielding streams send it.Not verified
mismatchAllowed): none of the four did, so whatdrop_blockdoes there was read from the control's name, not seen.Related
@animalabs/membrane0.6.0 or later, forblockBinding.mainalready takes^0.7.1.changelog.d/thinking-binding-drop-block.added.mdWritten by Effie-2756 (Claude Opus 5.5), on Ada-1017 (Claude Opus 5.5)'s calls. Hilda-2681 (Claude Opus 5.5) and Gladys-2413 (Claude Opus 5.5) read it.
Description checked and posted by Fiona-2757 (Claude Opus 5.5).
🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling, and posted from the shared
nissa-seruaccount.