Skip to content

fix!: mark the framework's notice openings in others' text, and keep supplied values on one line - #852

Merged
nissa-seru merged 6 commits into
anima-research:mainfrom
nissa-seru:ansel/f525-catchup
Oct 28, 2026
Merged

nissa-seru merged 6 commits into
anima-research:mainfrom
nissa-seru:ansel/f525-catchup

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

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.sourceBodyDigest is taken over the marked content, and ChannelRegistry.nameShown gives 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:212 in its dist), 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, at 8ff287dd, whose src is 0.28.0's, an agent that answered an invitation from #side with >>#side hello there, in the turn the message started, had >>#side hello there posted to #side in locus and hybrid mode, and hello there in 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

  • Openings in others' text are marked with a backslash before the bracket: in channel messages, pushed events, context injections, console and API input, Discord messages as they're stored, and 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.
  • The openings marked are the framework's own, built from one list (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_TAGS and MEMBRANE_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 own truncated and system tags instead.
  • A value a participant or a server supplied stays on its line where the framework names it in its own text: each run of line breaks in it, in any form, shows as one space, and its openings are marked. That covers channel labels, author and guild names, send failure reasons, servers' error texts, and servers' tool and toolset names, titles and descriptions.
  • The values the agent is told to pass back that this change covers are written in a form that reads back as exactly that value: a channel or message id, a toolset or instance selector, a tool's or argument's name, a schema literal and the drafts destination. One that holds a line break, an opening, a quote, a backslash, a lone surrogate or whitespace at its edges is written as JSON on one line, and an ordinary value reads as it did.
  • nameShown never 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.
  • A value cut to fit its bound, such as a long connector value or a tune-out dump's newest line, is cut before any opening that the cut would otherwise leave unmarked.
  • Refusals that list a channel by its id and server say how to reach it. A bounce adds the channelId that channel_publish, whose schema takes no serverId, 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 no channelId reaches it alone. A resend's refusal of a destination that didn't resolve calls the same publishRouteFor (src/framework.ts:17887). channel_publish, channel_open, channel_close and channel_decline refuse 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 that channelId and serverId, and channel_publish's names no serverId. channel_close and channel_decline build their refusal the way channel_open does (src/mcpl/channel-registry.ts:5116 and :5242, through :4167).
  • A toolsets tool selector is read as written where a tool the call can reach has that name exactly, and trimmed only where none does. When the call names a server, a tool with that exact name on another server doesn't count. Toolset and instance selectors go through the same selector (src/mcpl/toolsets-module.ts:104–105, called at :112–117).
  • The closed-channel invitation offers the reply that the mode of the agent it wakes reads: the one live agent the message names, and otherwise the delivery agent, which is the primary agent, or the first agent registered when there's no primary (src/framework.ts:15459–15461, :26496–26504).
    • explicit mode: a >> prefix, as before;
    • hybrid mode: a >>> prefix;
    • locus mode: the line says that plain speech lands where the latest word on where it lands says, a [routing] notice or channel_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;
    • disabled mode: only that pointer 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.
  • Generic resend examples are placeholders: "#<channel>" where they wrote "#channel", and @<person> where they wrote @person. The framework refuses a >> target or a resend that names either.

For hosts

  • Breaking: metadata.sourceBodyDigest is taken over a message's content as conversion leaves it, with the server's text marked: conversion marks it (src/mcpl/convert-block.ts:120 at 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's src reads sourceBodyDigest or storedBodyDigest.
  • Breaking: ChannelRegistry.nameShown gives 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, at 8ff287dd, 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 at 18db3a0c, this change before its last changelog edit and the merge of main, 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 passes nameShown's answer for such a channel to the prose resolver no longer gets the channel back.
  • The notice that a server's tools changed opens with [toolsets], where it had no tag.
  • The system prompt's placeholder for a non-text system injection reads [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.
  • An MCPL server's context injection is now marked (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.
  • A toolset or instance name that holds a line break or an opening is written as JSON in its [server/toolset:instance] label.

What this leaves

  • A gate rule's name isn't among the values passed back that Changes lists. wake_add_rule's refusal of an insertBefore or insertAfter that 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).
  • Messages stored before this change keep their text as stored. The exception is a Discord message a history sync rebuilds for another reason, which is written as new ones are. A live edit that leaves a row's text unchanged doesn't rewrite it only to mark it.
  • Other tools' results, such as 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].
  • The name a message is stored under: for console and API input, addMessage takes 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).
  • An opening split across two messages wasn't run. convertBlocks marks the blocks of the one message it's given (src/mcpl/convert-block.ts:118–120).
  • The invitation's reply line is written for one mode, the woken agent's or the delivery agent's. In 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.
  • Tags are matched in any case, up to a character that isn't a letter or digit, so a participant's [start here], [video] or [document draft] is marked too. In a run at 18db3a0c on Linux with Bun 1.4.4, markedText marked those three, and left [starter] and [videos] as written.
  • Characters that only look like a tag's letters or a bracket are outside the list.
  • A message that context-manager's per-message cap cuts wasn't run, so whether a cut can leave part of a marked opening isn't shown.
  • The context-manager and membrane lists match context-manager 0.23.0 and 0.23.1 and membrane 0.8.0: with the framework's own list, they hold the openings those releases write into the agent's own context or requests, outside a tool result. The openings that context-manager writes only into its own compression requests, such as [CM] Recall memory …, aren't listed. Nothing in the repository checks them against a later release of either.

Tests

This head, 71b8b7c5, merges main at 65bf22bc into this change. Its conflicts were in src/framework.ts's imports and in src/heartbeat/heartbeat.ts's tick line, which main had moved into tickText. In the merge, tickText takes its opening from NOTICE_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 from 18db3a0c in 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".
  • New test files: test/supplied-text.test.ts pins the rules (marking, one-lining, the JSON form and the cut to fit), and test/supplied-text-in-context.test.ts checks places end to end where the repository's fixtures reach them. test/exact-names-passed-back.test.ts checks that each form offered for a channel and a tool reaches its own, beside one whose name differs only by whitespace at its edges.
  • The invitation and the examples: in 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 a deliverTo names, 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. In test/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. In test/present-while-acting.test.ts, a channel_open result cut for its long backscroll keeps its word on where plain speech lands.
  • The refusals: in test/shared-channel-id.test.ts, for a candidate it shows by id and server, a bounce names the channelId that reaches it through channel_publish, and names no serverId. That route is the qualified name first, never an empty id, and channel_publish given it posts to that channel. channel_publish's and channel_open's refusals of a shared id list the turn's channels by their qualified names, as JSON, and only channel_open's adds the serverId line. No test asserts that line for channel_close or channel_decline, or the route line in a resend's refusal.

Red-first, in two runs whose receipts record no package versions:

  • On Linux with Node 24.22.1, with main 65bf22bc's src, the two modules this change adds to it (src/notice-tags.ts and src/supplied-text.ts), and the 19 test files this change adds or changes: 63 tests fail across 12 of those files, and trunk-channel-routing and the five tune-out test files pass. test/supplied-text.test.ts fails as a whole file, and none of its 17 tests runs. It imports convertBlocks (:5), which main's src/mcpl/convert-block.ts doesn't export.
  • With the src of 2b81e23f, the commit before 18db3a0c, and 18db3a0c's test files: 13 tests fail across five of the six test files 18db3a0c changes. This run's receipt records no Node version.

Not verified

  • No run against a live Discord or other real server. The end-to-end tests use the repository's speech-route fixture and toolset test servers.
  • The tests pin the helpers' rules and representative places, not every place that writes a supplied value.
  • Two cases of the locus-mode invitation weren't traced: whether context-manager's compression can drop a newer [routing] notice and keep an older one that names another channel, and a re-pin whose [routing] notice couldn't be stored.
  • A restart wasn't run with the locus-mode invitation. 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 fragments added: changelog.d/f525-supplied-text.breaking.md, f525-supplied-text.changed.md, f525-supplied-text.fixed.md and f525-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 normalizeLineBreaks and oneLine in src/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 before 18db3a0c. Gordon-2414 read each change since this pull request opened, up to a version that differs from 18db3a0c in one comment line, without running them, and read the merge of main hunk by hunk. Lottie-2661 wrote this description, from Ansel-2428's facts.

…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.

@github-actions github-actions Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 with metadata.sourceBodyDigest and ChannelRegistry.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:

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.

Comment thread src/mcpl/channel-registry.ts Outdated
Comment on lines +737 to +738
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.';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Comment thread src/mcpl/channel-registry.ts Outdated
Comment on lines +4540 to +4542
return showsAsItself(qualified)
? { text: qualified, reaches: true, byServer: false }
: { text: channelOnServer(c), reaches: false, byServer: true };

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Comment thread src/framework.ts Outdated
Comment on lines +15397 to +15398
const mode = this.deliveryAgent()?.proseRouting ?? 'locus';
const prefix = mode === 'hybrid' ? '>>>' : '>>';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Ansel-2428 added 5 commits October 27, 2026 16:10
…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.
@nissa-seru
nissa-seru merged commit f14e7fb into anima-research:main Oct 28, 2026
12 checks passed
tel-0s added a commit that referenced this pull request Oct 28, 2026
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>
nissa-seru pushed a commit to rufalupa666-netizen/agent-framework that referenced this pull request Oct 28, 2026
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.
nissa-seru pushed a commit to rufalupa666-netizen/agent-framework that referenced this pull request Oct 28, 2026
…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.
@nissa-seru

Copy link
Copy Markdown
Contributor Author

@antra-tess Flagging for your awareness this PR, which made the following changes to what residents see (in brief):

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