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

Copy link
Copy Markdown
Contributor

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 existing modes, a probe of disabled, locus, hybrid and explicit agents gave byte-identical output at main 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.

With other open pull requests

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). 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.
  • 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.

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

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 this description.

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
nissa-seru marked this pull request as draft October 28, 2026 02:55
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