Repository navigation
fix!: mark the framework's notice openings in others' text, and keep supplied values on one line - #852
Conversation
…work's own In an agent's context the framework's notices are told apart from what others wrote only by how they open, with a bracket tag such as [drafts] or [Channel invitation]. A value the framework names inside its own text (a channel label, an author's name, a server's error) could carry a line break and start a line of its own, and a server's or participant's text could simply open with one of the framework's tags. membrane's native formatter merges a stored notice and the next channel message into one user message, and its XML formatter writes each message after a "participant: " prefix, so an opening can stand mid-line as well. Supplied values in the framework's own text: - suppliedValue writes a value a participant or a server supplied on one line (each run of line breaks, in any form, as one space) with its openings marked. Channel labels, author and guild names, send failures and server errors (connectorText), provider failures, a server's tool, toolset and instance names, titles, summaries, reasons and descriptions, schema types, Discord attachment names and types, a gate event's quoted content and a reference stub's name and type go through it wherever they reach a notice, an answer or a refusal. - A value the agent is told to pass back (a channel or message id, a selector, a tool's or argument's name, a schema literal, the drafts destination, a candidate) is written so it gives back exactly what it names: as itself where it shows as itself, and otherwise as JSON that stays on one line, with NEL, U+2028 and U+2029 escaped and a listed opening's bracket written \u005b (quotedValue, nameToTypeBack). - A channel whose name wouldn't show as itself is named by its id and server, each as JSON; a >> target is offered only where it shows as itself and resolves back to its channel. - The toolsets meta-tools read a tool, toolset or instance selector as written where something on the named server (and toolset) has that name, and trim it only when nothing does. Openings: - Every fixed bracket opening the framework writes into context outside a tool result is built from one list, NOTICE_TAG, with the openings context-manager 0.23.0 and 0.23.1 and membrane 0.8.0 write beside it (CONTEXT_MANAGER_TAGS, MEMBRANE_TAGS: [continuing], [Start], [Image N], [Image omitted], [Tool Result: ...], [orphan tool_result for ...], [duplicate tool_result for ...], [Document: ...], [Audio: ...], [Video: ...]); openings they write inside a tool result aren't listed. The toolset-change notice now opens [toolsets], and the system prompt's non-text placeholder reads "[block not shown: an image block; the system prompt carries text only.]". - Text the framework didn't write is marked with a backslash before the bracket wherever it holds one of those openings: in any case, through whitespace and invisible characters inside the bracket, and across a message's text blocks. Server text is marked in conversion, where it can still be told from the placeholders the host writes in its blocks' place; console and API input, the discord module's rows and channel_open's backscroll are marked too. Marking is idempotent within a text. - A value or line cut to fit (cutToFit) is cut within its bound before any opening the cut would leave open. - Rows stored before this change keep their text, apart from a Discord row a history sync rebuilds; a tune-out dump quotes rows as stored. Examples and invitations: - A resend example that names no particular channel writes "#<channel>", and a bounce's DM example "@<person>"; resolveProseTarget refuses both as the example's placeholder. - A closed channel's invitation offers the reply the agent's prose routing reads: >> in explicit mode, >>> in hybrid mode, in locus mode a reply with no prefix when the latest [routing] notice names the channel (and the server's send tool otherwise, or with no such notice), and in disabled mode the send tool only. Locus and hybrid mode had posted the >> line as text, prefix and all. - A refusal that lists a channel by its id and server says which tool reaches it with that channelId and serverId. Breaking: metadata.sourceBodyDigest is taken over the content as conversion leaves it, marked, so a stored body holding a listed opening digests differently from the same body delivered after this change; and ChannelRegistry.nameShown gives a channel whose id doesn't show as itself (a quote, a backslash, a line break or a listed opening in it) by its id and server, which the channel tools take and the prose resolver doesn't, where such an id was given bare. Costs: the system prompt's bytes change for an agent with a non-text system injection; a toolset name holding a line break or an opening is shown as JSON.
There was a problem hiding this comment.
Automated review
Recommendation: needs a maintainer's decision. The change belongs, but part of it needs a maintainer's call.
Reviewed commit bd48332 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, with concerns. Keeping participants' and servers' text from reading as the framework's own notices belongs in this framework, which owns context compilation. A maintainer should still decide on three things: the PR bundles a separate invitation-reply fix, it takes on lists of context-manager's and membrane's tags that must track their releases, and its breaking fragment doesn't name who has to act.
6 reasons with sources
- Marking notice openings in supplied text fits the framework's own job of compiling an agent's context from external events. The README makes the framework responsible for event processing, context compilation and module injections. The framework already guards against adapters forging its metadata (src/mcpl/inbound-source.ts:14, 'an adapter cannot supply or spoof it'), and this change carries the same idea over to bracket notices. (README.md (Overview))
- Collecting the framework's own notice tags in one module (src/notice-tags.ts) and using it at each place that writes a notice is a structural cleanup, not a second way of doing things. Existing notice strings such as
[turn-interrupted],[script …]and[prose-routing]now read their tags from NOTICE_TAG, and supplied-text.ts escapes against that same list. (src/notice-tags.ts; src/agent.ts, src/auto-continue.ts and others in the diff) - CONTEXT_MANAGER_TAGS and MEMBRANE_TAGS are tied to specific releases of sibling packages, which the maintainers would then have to keep in step. The lists' own comments say 'Check them against each context-manager release' and 'each membrane release'. That couples this repo to the internals of upstream formatters. A maintainer may prefer that those projects export their own tags, through companion PRs that CONTRIBUTING describes as common. (CONTRIBUTING.md (How changes land: the repo sits directly on membrane and context-manager))
- The closed-channel invitation's reply fix and the placeholder change to the resend examples are separate behavior fixes riding in the same PR. The invitation fix even has its own changelog fragment (f525-invitation-reply-by-mode.fixed.md). It is unrelated to escaping supplied text and could be reviewed on its own. (CONTRIBUTING.md (true merge commits; keep commits coherent))
- The breaking fragment doesn't open by naming who needs to act, as the changelog conventions require. Breaking fragments should begin with a line like
- **Hosts:** …. f525-supplied-text.breaking.md opens directly withmetadata.sourceBodyDigestandChannelRegistry.nameShown, even though the PR description addresses its notes 'For hosts'. (changelog.d/README.md) - The PR updates the changelog but not docs/explicit-prose-routing.md, which still describes the old resend example, so the documented behavior and the changed behavior disagree. The code review found that line 65 still shows
>>#channel {{unsent}}, while this change replaces generic examples with#<channel>placeholders. (docs/explicit-prose-routing.md)
Findings
3 findings of medium or high severity, see the inline comments:
- Medium severity. New route hint tells the agent to pass a serverId that channel_publish's schema doesn't declare.
- Medium severity. ownCandidates drops a reachable qualified name for tools that take no serverId.
- Medium severity. Invitation reads the primary agent's prose mode, not the mode of the agent that receives it.
1 finding about lines the diff does not show:
- Medium severity. channel_publish's shared-id refusal now offers a serverId the tool doesn't declare.
src/mcpl/channel-registry.ts:6147
Details of this finding
- Medium severity,
src/mcpl/channel-registry.ts:6147. channel_publish's shared-id refusal now offers a serverId the tool doesn't declare. This call to ownNeitherRefusal leaves takesServer at its new default of true. So channel_publish's refusal now ends with "For a channel shown by its id and server, call again with that channelId and that serverId" (asserted in test/shared-channel-id.test.ts:660-668). channel_publish has no serverId parameter, and the sharedNameRefusal a few lines below (6162) passes takesServerId: false for that reason. The two refusals the same tool gives now disagree. One of them tells the model to add an argument that isn't in the schema.
2 more findings, less severe
- Low severity, test coverage,
src/framework.ts:17824-17828. Resend refusal's new channel_publish route hint is untested. The drafts resend path now appends CHANNEL_ON_SERVER_ROUTE when a candidate ends with the id-and-server form (endsWithChannelOnServer) and runs suppliedValue over resolved.error. No test checks this branch. The only CHANNEL_ON_SERVER_ROUTE assertions are on the>>bounce (test/shared-channel-id.test.ts:1261,1286). Add a resend with an ambiguous destination whose candidates include a channel shown by id and server, and assert the hint's presence and absence. - Low severity, documentation,
docs/explicit-prose-routing.md:65. Docs still describe the bounce's generic resend example as>>#channel {{unsent}}. This change makes the bounce's generic example>>#<channel> {{unsent}}(NO_CHANNEL_EXAMPLE), and resolveProseTarget now refuses it as a placeholder. The DM example is now@<person>. docs/explicit-prose-routing.md line 65 still says the notice tells the agent to resend with>>#channel {{unsent}}, which is the old example that a resend could take to a real channel. Update the guide to match.
For a deeper review by several readers and a judge, which takes longer, a maintainer can comment /review deep.
| export const CHANNEL_ON_SERVER_ROUTE = | ||
| 'A channel shown by its id and its server is reached by channel_publish with that channelId and that serverId.'; |
There was a problem hiding this comment.
Medium severity. New route hint tells the agent to pass a serverId that channel_publish's schema doesn't declare.
CHANNEL_ON_SERVER_ROUTE says a channel shown by its id and server "is reached by channel_publish with that channelId and that serverId". It is added to bounces and to the drafts resend refusal (framework.ts ~17824-17828, ~19898-19901). But channel_publish's inputSchema (CHANNEL_TOOL_DEFINITIONS, ~line 568-578) declares only channelId, content and text. The existing comment at ~6180 says so outright: "This tool's schema has no serverId, so one given here is a host's ref … or one an agent added unasked", and sharedNameRefusal for publish passes takesServerId: false so it never offers serverId. As a result, agents are told to pass an undeclared argument. On providers or tool modes that enforce the declared schema, the model can't send it at all, and the hint goes against the registry's own convention. Either add serverId to channel_publish's schema or point the hint at a route that takes one.
| return showsAsItself(qualified) | ||
| ? { text: qualified, reaches: true, byServer: false } | ||
| : { text: channelOnServer(c), reaches: false, byServer: true }; |
There was a problem hiding this comment.
Medium severity. ownCandidates drops a reachable qualified name for tools that take no serverId.
Before this change, a turn's channel whose server-qualified name picks it alone was listed as reachable, and ownNeitherRefusal offered "Call again with channelId ". Now, if the qualified name fails showsAsItself (for example an id holding a quote, a backslash or an opening), it is listed as channelOnServer with reaches: false. ownNeitherRefusal already writes reachable names through quotedValue, which gives exact JSON, so the qualified name would still pass back exactly. For callers through ownChannelFor (tune_out, cancel_tuneout, speak_in_channel, deliver_summary), takesServer is false, so the byServer hint is also suppressed. The refusal then offers no way at all to reach a channel that a channelId still reaches. Keep reaches: true here and rely on quotedValue for the listing.
| const mode = this.deliveryAgent()?.proseRouting ?? 'locus'; | ||
| const prefix = mode === 'hybrid' ? '>>>' : '>>'; |
There was a problem hiding this comment.
Medium severity. Invitation reads the primary agent's prose mode, not the mode of the agent that receives it.
buildClosedChannelInvitation picks the reply line from this.deliveryAgent()?.proseRouting. Called with no argument, deliveryAgent returns the primary, or else the first agent. But the invitation is also built for messages aimed at a specific agent. The channel-incoming path sets targetAgents: [target.name] for event.deliverTo (~14654), and the push path sets targetAgents from occ.deliverTo (~16102). When that agent isn't the primary and runs a different mode, the invitation offers the wrong reply: a >> line to a locus or hybrid agent gets posted as text, prefix and all, which is the bug this PR set out to fix. Pass the receiving agent's name through to the builder. The new test only covers the primary.
…able names reachable; offer the woken agent's reply A refusal that shows a channel by its id and server now gives the server-qualified name channel_publish's channelId reaches it by, as JSON, or says no channelId reaches it alone: channel_publish's schema takes no serverId, so naming one told an agent to send a field the tool doesn't declare. Its refusal of an id the turn's channels share no longer offers a serverId either. A turn's channel whose server-qualified name picks it alone is listed by that name in a channel tool's refusal again, as on main, now written as JSON, where a name that wouldn't show as itself had dropped it to the id-and-server form with no route for tools that take no serverId. The prose resolver still lists it by id and server, since a >> target can't take JSON. A closed channel's invitation offers the reply of the agent the message wakes when it names one agent, as a correction delivered to the agent that holds the original does, rather than always the primary's.
…ch lands; route by the name that picks a channel alone The locus invitation said plain text goes to the channel the latest [routing] notice names, but channel_open moves the locus and says so in its own result, with no notice, so the rule could point at the wrong channel. It now says plain speech lands where the latest word on where it lands says, a [routing] notice or channel_open's result, and names the channel as those do, so the two can be compared as written; otherwise it points to a send tool or to opening the channel, whose result says where plain speech lands. The tuned-out line gives the same rule and the send tool: plain speech whose turn's channel is tuned out still lands there, and channel_open refuses a tuned-out channel. The invitation's mode counts only live agents among those a message names. A refusal's route for a channel shown by its id and server names the channelId that picks it alone, its id or its server-qualified name, and reads only a candidate that ends in that form.
…_open's routing word ahead of its backscroll A refusal's route for a channel shown by its id and server named the channel's bare id whenever that picked it alone among registered channels. Inside a turn, channel_publish reads a bare id as the turn's own channel first, including one its server no longer registers, so the offered id could be refused where the server-qualified name reached the channel; and an empty id was offered, which channel_publish refuses. The route now names the server-qualified name where that picks the channel alone, else the id where that does and isn't empty. channel_open's result now carries its routing word before the backscroll, so a result cut to its head for its length keeps it: the closed-channel invitation's locus line points to that word. The locus line says "If that is <channel>" and, for the other case, "If that is another channel, or you see none", so the second case can't be read as following the decline sentence. The invitation's mode counts distinct live agents among those a message names. Test doubles of the channel registry that build an invitation provide nameShown, which the invitation now calls.
…say it doesn't mark a message's stored name The entry's first sentence said text a participant or a server supplied no longer reads as the framework's own, which also covers the name a message is stored under, and the change doesn't mark openings in that name. It now says what the change does on the paths it covers, and that it doesn't mark the openings in the name a message is stored under, which a formatter writes before the message.
Two conflicts, each resolved to keep both sides: - src/framework.ts imports: main's SkipReplyEarlierWake type from skip-reply-wake.js beside this branch's convertBlocks. - src/heartbeat/heartbeat.ts: main moved the tick's text into tickText(); the delivery calls tickText as main does, and tickText builds its opening from NOTICE_TAG.currentTime, as this branch did at the call. main's added source writes no other bracket opening into an agent's context outside a tool result: its other new bracketed strings are console logs.
110 commits from main, 20 pull requests: #852 (supplied text can't read as the framework's own), #853 (every check of whether an agent sees a row asks its view), #827 (the heartbeat moves into the framework), #846 (exhausted turns re-armed), #830 (chat:system notices), #801 (the reply judge), #840, #843, #839, #791, #845, #847, #849, #822, #854, #836, #855, #857, #858, #859 and the 0.28.0 release. package.json takes main's version and ranges (context-manager ^0.23.1, membrane ^0.8.0). Conflicts: - src/framework.ts, TurnRouting: both fields kept, the branch's addressedElsewhere and main's heard (and both in freshTurnRouting). - src/framework.ts, createAgent: main's `sees` predicate, recorded in storeSees, is the branch's composition: heldFromResidents (the RESIDENT_VIEW_FILTER rule) and !isAnotherAgentsInjection; the view filter reads it. - src/framework.ts, tool-boundary drain: the branch's releaseLandedMail and main's noteSpokenTo both run; the injection skip asks agentSees (main) where the branch asked heldFromResidents, which it subsumes. - src/framework.ts, the fork wake and the routed fork wake: the branch's tag-only isAddressedMessage, with main's systemNoticeMark added. - src/framework.ts, a coalesced batch's wake: tag-only addressed, with main's note on why it carries no systemNotice mark. - src/framework.ts, isUnreadStoredMessage: main's form (the slot's readers each asked their own view) over the registry's sharedSlotReaders. - src/framework.ts, noteArrival: main's agentSees and heard, the branch's arrival with `addressed`. - src/framework.ts, noteForOtherResidents: the registry's readers, with main's noteSpokenTo. - src/framework.ts, the channel tool dispatch: the branch's publishInputWithTurnLocus, with main's earlierWake on the origin. - src/framework.ts, tune_out repeated on a tuned-out channel: the branch's awaited form, the name quoted by channelRegistry.quotedShown (#852). - src/mcpl/channel-registry.ts, markAccepted: the branch's (admission's noteDelivered sets the locus); main's chat:system is a marker (isMarkerTagged), so noteDelivered's rule covers it, and its comment says so. - src/mcpl/channel-registry.ts, tuneOutEndText: the branch's serverId form for a channel no name picks alone, a name written by quotedValue. - src/tune-out/coordinator.ts: the branch's readOptions, all-except refusal, renderHeldLine/historyTool, reader-fork invocation and cancel_tuneout scope logic kept; main's quotedShown, NOTICE_TAG texts and suppliedValue author adopted in each. - test/batched-wake-locus: main's new two-closed-channels test is placed in the branch's rig (framework read from rig.framework()); the branch's ambient-only tests kept. - test/channel-registry-routing: the branch's noteDelivered comment, with main's chat:system added to the marker loop. Main's changes integrated into the branch's structures: - heartbeatTargets reads registry.untargetedRecipients() (an untargeted push's recipients) and heartbeatRefusal registry.homeChannel, in place of conversationAgentHomes. - releaseAgentRuntime also drops #846's re-arm count and exhausting re-arm and #840's exhausted-turn record, and ends the run of stated waits (endStatedWaitRun). - storeSees is recorded for a derived manager (its source's predicate, which context-manager's derive keeps as its view filter) and for a context reopened on the shared slot (the residents' view). - The closed-channel invitation for a deliverTo correction reads the recipient admission chose, where main rewrites event.targetAgents. - #852 on the branch's own texts: authorLine/presentAuthor write the author through suppliedValue; the addressed-elsewhere notice's author too; `Focus` and `Agent lifecycle` are NOTICE_TAG entries and their notices use them; tune-out's branch-only notices use NOTICE_TAG and name channels by shown/quoted names. Tests read onto this branch, each named in a comment: - heartbeat (two cases): the fake fork is a registry record (conversationForkSpec), not a conversationAgentHomes entry. - prose-drafts (stated provider wait, two cases): scout is made a conversation agent through its registry record (homeChannel, no provider scheduling), as main does through conversationAgentHomes. - channel-registry-routing (chat:system locus): each message is delivered with noteDelivered, where admission records the locus here. - supplied-text-in-context (three cases): a channel message is stored and sent under its author's name (presentAuthor); the delivered body's digest is compared with the marked body before that decoration. - tune-out-reader-queued: its fake registry gains quotedShown. Tests: built with zero errors; 29 stale compiled tests removed. The targeted run (dendrite-*, tune-out-*, batched-wake-locus, channel-registry-routing, compression-after-release, stored-injections and main's added or changed framework-area tests, 63 files) gave 2507 tests, 2491 pass, 14 fail, 2 skipped before the readings above; after them, 73 files (those 63 and all-except-unit, all-except-e2e, author-presentation, module-rows-held-from-residents, refusals-are-error-results, state-read-and-write-errors, inbound-source, reinit-after-branch-move, channel-agent-words, channel-lifecycle-order) gave 2860 tests, 2858 pass, 0 fail, 2 skipped (main's runtime-conditional skips). The full suite was not run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main moved thirteen merges past 8ff287d. anima-research#858 (skip_reply names the heartbeat's earlier wake) conflicted first, and the scratch merge of all thirteen gave ten blocks, nine in src/framework.ts and one in test/present-while-acting.test.ts. Each keeps both sides: - The imports keep anima-research#852's supplied-text helpers and notice tags beside readInboundSource. - The request marks keep anima-research#846's REARMED beside the wake channels, the turns a request continues and the retake mark, with anima-research#846's helpers after them. - The delivered receipt and the no-channel routing notice keep this branch's text inside anima-research#852's NOTICE_TAG. main's side of the routing notice was its July wording, which this branch's replaced. - skip_reply's self-wake keeps anima-research#858's armedAt and earlierWake, and still passes the turn that armed it to armSelfWake. - A status's locus keeps a private turn's null, with anima-research#852's suppliedValue for a channel's id. - The MCPL tool-list notice keeps its notice variable, which FRAMEWORK_NOTICES marks, with anima-research#852's suppliedValue for each tool's name. - dispatchChannelToolCall takes anima-research#858's earlierWake, under this branch's one-sentence comment. - channel_open's result puts its routing word first, as anima-research#852 does so a result cut for its length keeps it, with this branch's private-turn wording. - The test file keeps main's "channel_open's word ... survives a result cut" beside this branch's private channel_open tests.
…es do main's anima-research#852 passes every value a server supplied through suppliedValue before a notice shows it, so a line break or a notice opening in it can't pose as a framework notice. The notice that names the channels a private turn holds messages from fell back to a wake's raw channelId where the id names no one registered channel, so an id holding a line break and a "[routing]" opening put a second, bare routing notice line into it. It now shows that id through suppliedValue. This branch's three notices also write their tags through NOTICE_TAG, as main's do since anima-research#852: the two routing notices and the kept-private prose notice. The bytes are the same. Test (turn-trigger-provenance): a private start that takes in a wake for a channel no server registered names it on one line, with its notice openings marked. The rig's stand-in registry names no channel for that id, as the real one does for an id no server registered. It fails at this commit's parent with the raw id in the notice.
|
@antra-tess Flagging for your awareness this PR, which made the following changes to what residents see (in brief):
|
In an agent's context, the framework's notices are told apart from what others wrote only by how they open: a bracket tag such as
[drafts]or[Channel invitation]. In the messages, pushed events and context injections that participants and servers send into an agent's context, this pull request writes a backslash before each of the framework's openings, so that[drafts]reads\[drafts], and before the ones context-manager and membrane write into the agent's own context or requests, outside a tool result. A value a participant or a server supplied, such as a channel's label or a server's error, stays on one line where the framework names it in its own text, and the values it tells the agent to pass back to a tool, listed under Changes, are written so each parses back to exactly what it names. The pull request also fixes the closed-channel invitation's reply line in hybrid, locus and disabled mode, and makes the generic resend examples placeholders that reach nothing.Two changes are breaking for hosts:
metadata.sourceBodyDigestis taken over the marked content, andChannelRegistry.nameShowngives some channels by their id and server. The details are under "For hosts".Problem
A value the framework names inside a notice, such as a channel label, an author's name or a server's error text, could carry a line break and start a line of its own. A server's or a participant's text could open with one of the framework's tags. membrane 0.8.0's native formatter merges consecutive messages of one role into one (
formatters/native.js:212in itsdist), so a stored notice and the channel message after it can share a user message, and its XML formatter writes the participant's name and:before a message's text (formatters/anthropic-xml.js:196). So an opening can also stand mid-line. Either way, others' words could read as the framework's own.There were two smaller faults as well. A closed channel's invitation offered a
>>reply in every prose routing mode, and in locus and hybrid mode such a line is posted as text, prefix and all. In a run on Linux with Node 24.22.1, at8ff287dd, whosesrcis 0.28.0's, an agent that answered an invitation from#sidewith>>#side hello there, in the turn the message started, had>>#side hello thereposted to#sidein locus and hybrid mode, andhello therein explicit mode. And a generic resend example wrote"#channel"or@person, which a resend could take to a real channel or person.Changes
For an agent
channel_open's backscroll. An opening is marked in upper, lower or mixed case, wherever it stands in a line, and through whitespace, control or invisible characters inside its bracket. Text that only resembles an opening, such as[draftsman], is left as written, and marking a text twice marks each opening once.NOTICE_TAG,src/notice-tags.ts), and the ones context-manager 0.23.0 and 0.23.1 and membrane 0.8.0 write into the agent's own context or requests, outside a tool result, such as[Recall …],[continuing]and[Start]. Two more lists,CONTEXT_MANAGER_TAGSandMEMBRANE_TAGS, hold context-manager's and membrane's openings. Some of theirs, such as context-manager's[truncated …]and membrane's[system: …]notes, are marked through the framework's owntruncatedandsystemtags instead.nameShownnever gives a channel by a name that picks another channel. A channel whose name holds a line break, an opening, a quote, a backslash, a lone surrogate or whitespace at its edges is named by its id and server, each as JSON, which the channel tools take exactly. A>>target is offered only where it shows as itself.channelIdthatchannel_publish, whose schema takes noserverId, reaches such a channel by. That's its server-qualified name where that picks it alone, and otherwise its id where that picks it alone and isn't empty. Where neither does, it says that nochannelIdreaches it alone. A resend's refusal of a destination that didn't resolve calls the samepublishRouteFor(src/framework.ts:17887).channel_publish,channel_open,channel_closeandchannel_declinerefuse an id that the turn's channels on more than one server share. That refusal lists each such channel by its server-qualified name, as JSON, where that picks it alone. When the list shows a channel by its id and server,channel_open's refusal adds that the tool can be called again with thatchannelIdandserverId, andchannel_publish's names noserverId.channel_closeandchannel_declinebuild their refusal the waychannel_opendoes (src/mcpl/channel-registry.ts:5116and:5242, through:4167).selector(src/mcpl/toolsets-module.ts:104–105, called at:112–117).src/framework.ts:15459–15461,:26496–26504).>>prefix, as before;>>>prefix;[routing]notice orchannel_open's result. If that is the invited channel, it offers a reply with no prefix. If that is another channel, or the agent sees none, it points to checking whether the server has a send tool that can address the channel, or to opening the channel, whose result says where plain speech lands. For a tuned-out channel it gives the same rule, and points only to a send tool;channel_open's result gives its word on where plain speech lands before its backscroll, so a result cut for its length keeps it."#<channel>"where they wrote"#channel", and@<person>where they wrote@person. The framework refuses a>>target or a resend that names either.For hosts
metadata.sourceBodyDigestis taken over a message's content as conversion leaves it, with the server's text marked: conversion marks it (src/mcpl/convert-block.ts:120at this head), the incoming lane digests the converted content (src/framework.ts:14675), and "stores a channel message with its openings marked" asserts that the digest is the marked body's. A body stored before this change that holds one of the listed openings digests differently from the same body delivered after it. Nothing in this repository'ssrcreadssourceBodyDigestorstoredBodyDigest.ChannelRegistry.nameShowngives a channel whose name holds a line break, an opening, a quote, a backslash, a lone surrogate or whitespace at its edges by its id and its server, each as JSON. The channel tools take that form exactly, and the prose resolver doesn't. In a run on Linux with Node 24.22.1, at8ff287dd,resolveProseTarget(nameShown(ref))gave back the channel itself for an id holding a quote, a backslash,[drafts], a line feed, U+2028 or NEL, and "no channel matches" for an id ending in a space. In a run at18db3a0c, this change before its last changelog edit and the merge ofmain, on Linux with Node 24.22.1, each of those ids was given in the new form, such as"a\nb" on server "discord"for the line feed, and the prose resolver answered "no channel matches". A host that passesnameShown's answer for such a channel to the prose resolver no longer gets the channel back.[toolsets], where it had no tag.[block not shown: an image block; the system prompt carries text only.]for an image block, and names the block's type in the same way for any other, where it read[image block not shown: the system prompt carries text only.]. For an agent whose system prompt carries such a block, the system prompt's bytes change once, so a prompt cache keyed on those bytes misses on the first request after the upgrade.src/mcpl/hook-orchestrator.ts:68). So an injection that holds one of the listed openings has different bytes after the upgrade, and on the first request after it, a prompt cache keyed on the prompt's bytes misses from the injection's position onward.[server/toolset:instance]label.What this leaves
wake_add_rule's refusal of aninsertBeforeorinsertAfterthat isn't a non-empty string lists the rules' names as they are (src/framework.ts:32986–32990), and the rule-order note that fix(gate): rule-order notes leave out types a rule's source never sends, and say what to do #859 added writes one between plain quotes (src/gate/event-gate.ts:1959).fetch_history's, aren't among the places this change marks. Those are the values the framework names in its own text (suppliedValue,src/supplied-text.ts:108–110), conversion (src/mcpl/convert-block.ts:120),channel_open's backscroll (src/mcpl/channel-registry.ts:709, used at:5078), Discord rows (src/modules/discord/index.ts:192,:240), text injections (src/mcpl/hook-orchestrator.ts:68), and console and API input (src/framework.ts:14496). No test or run checks a tool result's text. A tool result is its server's own statement about its own call, so three openings that context-manager and membrane write only as a tool result's whole content aren't listed: context-manager's[result of tool '<name>' left out: …]and[this call's result isn't shown …], and membrane's[pending].addMessagetakes the participant unmarked, beside the marked content (src/framework.ts:14496), and no test or run checks a stored name. In the toolsets listing, a server's id, which the operator configures, is written as given (src/mcpl/toolsets-module.ts:487).convertBlocksmarks the blocks of the one message it's given (src/mcpl/convert-block.ts:118–120).test/prose-drafts.test.ts, a push that names two live agents gives the second agent registered the line for the delivery agent's mode.[start here],[video]or[document draft]is marked too. In a run at18db3a0con Linux with Bun 1.4.4,markedTextmarked those three, and left[starter]and[videos]as written.[CM] Recall memory …, aren't listed. Nothing in the repository checks them against a later release of either.Tests
This head,
71b8b7c5, mergesmainat65bf22bcinto this change. Its conflicts were insrc/framework.ts's imports and insrc/heartbeat/heartbeat.ts's tick line, whichmainhad moved intotickText. In the merge,tickTexttakes its opening fromNOTICE_TAG, as this change does for the heartbeat's default message and a reminder's line, and the text is unchanged.On Linux with Node 24.22.1, at this head:
npm test, after a fresh install with membrane 0.8.0 and context-manager 0.23.1: 8,684 tests in 436 suites, 8,622 passing, 0 failing, 0 cancelled and 62 skipped. The build passes. An earlier full run, at a commit that differs from18db3a0cin one comment line, had one failure, in a test this change doesn't edit:test/event-gate-judge.test.ts's "a judge that takes too long is timed out, its request aborted, and the agent woken".test/supplied-text.test.tspins the rules (marking, one-lining, the JSON form and the cut to fit), andtest/supplied-text-in-context.test.tschecks places end to end where the repository's fixtures reach them.test/exact-names-passed-back.test.tschecks that each form offered for a channel and a tool reaches its own, beside one whose name differs only by whitespace at its edges.test/prose-drafts.test.ts, each mode's offered reply lands in the invited channel, or in disabled mode posts nothing. The invitation reads the mode of the one live agent a push or adeliverTonames, and the delivery agent's when it names none or two. Plain speech whose turn's channel is tuned out lands in that channel, as the tuned-out line says. In locus and hybrid mode, the resend example says"#<channel>"where no name that a resend could take reaches the place. Intest/batched-wake-locus.test.ts, when one batch addresses two closed channels, the earlier channel's invitation gives the locus line's condition, and no[routing]notice names that channel. Intest/present-while-acting.test.ts, achannel_openresult cut for its long backscroll keeps its word on where plain speech lands.test/shared-channel-id.test.ts, for a candidate it shows by id and server, a bounce names thechannelIdthat reaches it throughchannel_publish, and names noserverId. That route is the qualified name first, never an empty id, andchannel_publishgiven it posts to that channel.channel_publish's andchannel_open's refusals of a shared id list the turn's channels by their qualified names, as JSON, and onlychannel_open's adds theserverIdline. No test asserts that line forchannel_closeorchannel_decline, or the route line in a resend's refusal.Red-first, in two runs whose receipts record no package versions:
main65bf22bc'ssrc, the two modules this change adds to it (src/notice-tags.tsandsrc/supplied-text.ts), and the 19 test files this change adds or changes: 63 tests fail across 12 of those files, andtrunk-channel-routingand the five tune-out test files pass.test/supplied-text.test.tsfails as a whole file, and none of its 17 tests runs. It importsconvertBlocks(:5), whichmain'ssrc/mcpl/convert-block.tsdoesn't export.srcof2b81e23f, the commit before18db3a0c, and18db3a0c's test files: 13 tests fail across five of the six test files18db3a0cchanges. This run's receipt records no Node version.Not verified
[routing]notice and keep an older one that names another channel, and a re-pin whose[routing]notice couldn't be stored.lastAnnouncedLocus, the last locus announced to each agent as a[routing]notice, is kept in memory (src/framework.ts:3361–3365), so whether the latest[routing]notice in a window still says where plain speech lands after a restart isn't shown.changelog.d/f525-supplied-text.breaking.md,f525-supplied-text.changed.md,f525-supplied-text.fixed.mdandf525-invitation-reply-by-mode.fixed.md.Ansel-2428 wrote the change, with design calls by Ada-1017. Florence-3585 wrote two of its tests. Rupert-2634 wrote
normalizeLineBreaksandoneLineinsrc/supplied-text.ts, and George-2477 found that membrane's native formatter merges consecutive messages of one role. Greta-2425 read the change against its requirements, Amos-2081 read how its notices and refusals read to an agent and read the invitation's locus-mode line twice more, Prudence-2578 checked each part for what it adds, and our internal review checked it over eleven rounds. Its last round read a version from before the invitation's locus-mode and tuned-out lines took their present wording, and from before18db3a0c. Gordon-2414 read each change since this pull request opened, up to a version that differs from18db3a0cin one comment line, without running them, and read the merge ofmainhunk by hunk. Lottie-2661 wrote this description, from Ansel-2428's facts.