Skip to content

feat(prose): add a 'named' prose routing mode, which posts no plain text and needs a channelId for channel_publish - #868

Draft
nissa-seru wants to merge 1 commit into
anima-research:mainfrom
nissa-seru:hamish/named-prose-mode
Draft

nissa-seru wants to merge 1 commit into
anima-research:mainfrom
nissa-seru:hamish/named-prose-mode

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

Two reports, anima-research/connectome-host#96 and #551, describe an agent's plain text going where the agent didn't choose. At main of October 28, e85c57db9a, no prose routing mode kept it from every channel: under disabled, plain text still went to modules' speech handlers, and the framework's own Discord module posts a text-only turn's text, and a channel_publish with no channelId went to a default channel. This adds an opt-in mode, proseRouting: 'named', in which an agent's plain text goes to no channel and no speech handler, and its channel_publish must name a channel.

An agent whose plain text should go nowhere, and whose publishes should name their channel

  • base (main at e85c57db9a): no mode does both. disabled sends none of the agent's plain text through the channel registry, but hands it to modules' speech handlers, and the framework's own Discord module posts a text-only turn's text to its reply context's channel. Its channel_publish with no channelId posts to a default channel: in a probe, a heartbeat tick's call went to the channel the run's earlier messages came in on.
  • head: a named agent's plain text is never posted to a channel or handed to a module's speech handler, and its channel_publish with no channelId is refused: "Nothing was posted: channel_publish needs a channelId, since nothing is sent to a default channel. channel_list shows the channel ids." Each of its requests carries a system line saying so, and its tools and its receipt say the same. For a channel whose server hasn't declared it inbound-only, its closed-channel invitation offers channel_publish with an id that reaches that channel alone, where there is one and the agent may call channel_publish, and otherwise says to check whether the server exposes a send tool that can address the channel.

For the four modes main had at e85c57db9a, disabled, locus, hybrid and explicit, a probe of an agent in each gave byte-identical output there and at this head. main has since added a fifth, 'locked'. By its doc, its unprefixed prose goes to one destination the agent chose with prose_locus, or to the host's proseLock until it chooses, and a module that handles speech itself still receives it, as in every mode. Neither the probe nor the tests ran at today's main.

What it leaves: the framework's own Discord module isn't changed, so a named agent's discord--reply still goes to the module's reply context, a channel the call doesn't name. A module's own channel_publish for a named agent isn't refused, by reading, and no test runs it. The closed-channel invitation is written for one agent, the woken one or else the delivery agent, so in a window residents in different modes share, a named resident can read one written for another mode, which can ask for a reply in plain text or with a >> or >>> prefix, and following that posts nothing. No test runs a switch into named mode or previewActivation for a named agent, and no run used a live provider.

It stays a draft until a round of ours brings it onto main as it is now and answers its automatic review.

  • Changelog fragment added: changelog.d/named-prose-routing.added.md.
Credits

Hamish-2699 made the change, from a design by Ada-1017 that Florence-3585, Imogen-2490, Naomi-2422 and Jerome-2511 reshaped. Daniel-2612 wrote the description, and Ramona-2416 put it in this shape.

Details for agents

Head e7b5489b9031, into main. Its probe and runs are at main e85c57db9a, of October 28, and main is now 0c570d8f2d.

This adds an opt-in proseRouting: 'named'. A named agent's plain text isn't posted to a channel or handed to a module's speech handler, and its channel_publish with no channelId is refused instead of posting to a default channel. Every request for its turns carries a line at the system position that says so and names how to speak, the same line in each of the agent's requests. Its tools and its receipt say the same, and its closed-channel invitation offers channel_publish with an id that reaches the channel, where one does and the agent may call it. For the four modes that existed on October 28, a probe of disabled, locus, hybrid and explicit agents gave byte-identical output at that day's main, e85c57db9a, and at this head.

Problem

Two reports describe an agent's plain text going where the agent didn't choose. anima-research/connectome-host#96 reports internal continuity summaries routed as ordinary public chat when the current chat locus was sticky, and #551 reported subagents publishing their plain text to the global fallback channel.

In the probe of the four modes that Tests describes, at main e85c57db9a, the disabled agent posted none of its plain text. But its channel_publish with no channelId, made in a heartbeat tick, posted to disc:guild:other, the channel the run's earlier messages came in on. Changing disabled would change what existing disabled agents meet, so this adds a mode instead.

Changes

  • The mode. 'named' joins AgentConfig.proseRouting and Agent.proseRouting, with a doc comment in src/types/agent.ts.

  • Plain text. A named agent's turn routes as a disabled turn does (turnModeOf, src/framework.ts:364), and gets no primer. Its completed speech and thoughts aren't dispatched to modules' speech handlers (:23049). Its sleep call posts nothing.

  • The system line. For a named agent, each turn adds a system-position injection in the prose-routing namespace (:21353), from namedProseLine (:353). It reads, for an agent that may call channel_publish:

    Your plain text is never posted to any channel, in any turn. It stays in your own context, where this host's console and observers can see it. To say something in a channel or to a person, call a send tool and name where it goes: channel_publish with a channelId, or a tool that names a person or a message, such as a DM or reply tool.

    An agent that may not call channel_publish is pointed to "a tool that names a channel, a person or a message" instead. For a subagent (an ephemeral run), the second sentence says its final reply goes back to whoever started it and other plain text stays in its own context.

  • Its tools.

    • channel_publish requires channelId, and its description says nothing is sent to a default channel.
    • skip_reply's description says to reply with a tool that names where it goes.
    • think drops the sentence about same-round routing.
    • sleep says nothing is announced, and drops announce and message.

    Other agents' descriptions are built from the same strings as before.

  • The refusal. The channel registry refuses an agent-origin channel_publish with no channelId for a named agent, through a namesItsChannels option the framework passes (src/mcpl/channel-registry.ts:6157, src/framework.ts:29076). The refusal reads "Nothing was posted: channel_publish needs a channelId, since nothing is sent to a default channel. channel_list shows the channel ids." A named conversation fork's channel_publish with no channelId is refused too, not sent to the fork's home (:33227).

  • A fork's drafts resend with no destination is refused for a named agent, instead of going to the fork's home (:18119).

  • The receipt after a turn with plain text says how many plain-speech segments were "kept in your context, sent to no one (only a send tool that names where it goes reaches anyone)".

  • A refusal by the provider of a named agent's turn gets no emoji reaction on the latest inbound message (reactToRefusal, :11173).

  • The closed-channel invitation's first option, for a named agent that may call channel_publish, is a channel_publish call with the channelId that reaches that channel alone. Otherwise it says to check whether the server exposes a send tool that can address it. publishNameFor is taken out of publishRouteFor, which now calls it.

  • Log lines. The NOT routed lines say why: a silent turn, or the configured mode.

  • changelog.d/named-prose-routing.added.md, new.

  • test/named-prose-routing.test.ts, new, with 13 tests.

Tests

Each of the 13 tests drives a named agent. Several also check that another agent meets what it did before.

  • Plain text:
    • none is posted in a channel message's turn, a heartbeat tick's or a self-wake's, >> and >>> lines included, and no primer is given;
    • none reaches a module's speech handler, while another agent's still does;
    • its sleep call posts nothing, and it's told of no undeliverable reply.
  • The system line:
    • it's in every turn's request, and in a turn's later provider rounds, through a real Membrane with membrane's Anthropic adapter building each wire request;
    • it names channel_publish only where the agent may call it;
    • a subagent's version says where its final reply goes.
  • channel_publish:
    • with no channelId it's refused and posts nothing, with a serverId alone, with an empty channelId, from a script, and as an operator's puppeted call;
    • naming a channel posts there.
  • Its tools agree with the rule, while another agent's say what they did.
  • The receipt says nothing was delivered and the words stayed in its context.
  • The invitation offers channel_publish naming the channel.
  • The step that reacts to a provider's refusal (reactToRefusal, called directly) adds no emoji reaction for a named agent, while it still adds one for a locus agent.
  • A conversation fork's channel_publish or drafts resend with no destination is refused, not sent to its home.

On Linux with Node 24.22.1, context-manager 0.23.1, membrane 0.8.0 and chronicle 0.4.0, in a clean checkout of e7b5489b90:

  • The suite: tsc clean, then 8,806 tests, 8,744 pass, 0 fail, 62 skipped.
  • With e85c57db9a's src/framework.ts, src/mcpl/channel-registry.ts, src/agent.ts and src/types/agent.ts: all 13 new tests fail.

The four existing modes. A probe drives a disabled, a locus, a hybrid and an explicit agent through the same steps, among them a plain reply, >> and >>> replies, and a heartbeat tick that calls channel_publish with no channelId. Its output records what each step posted, the window's rows, and the system and tool fingerprints of each of 21 requests. Run under Bun 1.4.4 with the same packages, at main e85c57db9a and at e7b5489b90, the two outputs are byte-identical.

Not verified

  • A module's own channel_publish for a named agent, a callTool with module origin, isn't tested. The registry's refusal applies where origin?.kind === 'agent' (src/mcpl/channel-registry.ts:6157), and a module's callTool runs the call with { kind: 'module' } as its origin (src/framework.ts:4092).
  • A shared window with agents in different modes. The closed-channel invitation is built for one agent, the woken agent or else the delivery agent (const invited = woken ?? this.deliveryAgent();, src/framework.ts:15702). An invitation written for a locus agent can ask for a reply with no prefix (src/framework.ts:15708 at this head), and one for a hybrid or explicit agent for one prefixed >>> or >> (:15726); a named agent's plain text, prefixed lines included, posts nothing. No test has residents in different modes reading one window.
  • A switch into named mode on an agent that already ran in another mode isn't tested.
  • previewActivation for a named agent isn't tested.
  • main's fifth mode. main has since added proseRouting: 'locked' (src/types/agent.ts:344 at 0c570d8f2d, where e85c57db9a's :341 has four modes). Its doc there (:333–343) says unprefixed prose goes to one destination the agent chose with prose_locus, or proseLock until it chooses, and that a module that handles final speech itself, such as the in-process Discord module, receives it in every mode. That's a reading of the doc; neither the probe nor the tests ran at 0c570d8f2d.
  • The framework's own Discord module. This change doesn't touch src/modules/discord/index.ts. At e85c57db9a it registers as the speech handler for every agent (:446), which is how a disabled agent's plain text reaches it: the speech dispatch's condition names no mode (src/framework.ts:22964). Its reply tool requires only content (:590–599 at this head), and sends to its reply context's channel (:1579–1581), so a named agent's discord--reply reaches a channel the call doesn't name. No test here runs the module.
  • Platforms and providers. Every run here was on Linux. The tests use stand-in providers, apart from the later-rounds test's recording Anthropic adapter, which sends nothing. No run used a live provider.

A new proseRouting value, 'named', that an agent opts into: its plain text
is never posted to any channel, and nothing the framework sends for it
falls back to a default destination. Existing recipes are unmoved, since
no other mode changes.

Its turns route as 'disabled' does (turnModeOf), so every publication path
posts none of its plain text, >> and >>> lines and heartbeat ticks
included, with no branch at any of them; it gets no primer. Its completed
speech isn't handed to modules' speech handlers either, which can post it
(the bundled Discord module does). The sleep announcement reads the
configured mode, so it gains the one branch: nothing is announced.

Every request for its turns carries one constant line at the system
position, beside the subconscious's dispositions, so the rule survives a
fold and an allowedTools that leaves its tools out. The line names
channel_publish only where the agent may call it, and tells a subagent
(an ephemeral run) that its final reply goes back to whoever started it.

Its tools agree with the rule: channel_publish requires channelId and
promises no default, skip_reply says a reply is sent with a tool that
names where it goes, think drops the same-round routing sentence, and
sleep says nothing is announced and drops announce and message. No other
agent's tool list changes. Its channel_publish with no channelId is
refused in the registry (namesItsChannels), where every agent-origin
publish passes, a puppeted or script call included; a named conversation
fork's such call skips the home fill, so it's refused there too. A named
fork's drafts resend with no destination is refused, not sent home.

Its receipt says the segments were kept in its context and sent to no
one, and a provider's refusal of its turn is marked on no message, since
the latest inbound message isn't a destination it named. A closed-channel
invitation offers channel_publish with the channelId that reaches that
channel alone (publishNameFor, which publishRouteFor now uses too), where
one does and the agent may call it, and says nothing about plain text, so
its words hold for any recipient of the shared message that can call
channel_publish. The NOT routed log lines say why: a silent turn, or the
configured mode.
@nissa-seru

Copy link
Copy Markdown
Contributor Author

/review

@anima-review

anima-review Bot commented Nov 3, 2026

Copy link
Copy Markdown
About these findings

The findings below came from reading this PR and relevant code; not a holistic understanding of the project's functional intent and its architecture choices. Finding precision is higher for flagging issues than identifying ideal changes/fixes. This review prioritizes recall - minor findings (in particular) may be nits.

Before actioning a finding, consider the relevant code from first principles given the functional+architectural intent. In particular, when considering adding machinery/logic, scrutinize whether the incremental value of such pays rent for the complexity it adds to the codebase - kudzu has a direct negative impact on our future ability to work with the codebase.

Automatic review of e7b5489b9031.

Findings, most severe first:

@nissa-seru

Copy link
Copy Markdown
Contributor Author

/context-deltas

@anima-review

anima-review Bot commented Nov 7, 2026

Copy link
Copy Markdown

Automatic context view of e7b5489b9031 against 0c570d8f2d8c.

What a context view runs and what its checks judge: CHECKS.md.

This head couldn't be compared: it doesn't merge onto 0c570d8f2d8c.

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