Skip to content

mcpl: a chat:system notice never decides where an agent's plain speech goes - #830

Merged
antra-tess merged 2 commits into
mainfrom
feat/chat-system-notices
Oct 27, 2026
Merged

antra-tess merged 2 commits into
mainfrom
feat/chat-system-notices

Conversation

@antra-tess

@antra-tess antra-tess commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

The bug

A world's weather line arrived as channels/incoming tagged chat:ambient (the tag for overheard chatter). The gate skipped it, so it woke nobody, but the registry still accepted it as the most recent inbound message, and defaultPublishChannel became the world. The next heartbeat turn had no channel of its own, so resolveLocus took that fallback and spoke a reply meant for a DM aloud in the world. Reactions, edits and deletions were already exempt from that pointer. Nothing exempted a notice, because the core vocabulary had no word for one.

The change

MCPL RFC-001 revision 3 adds chat:system: a notice from the platform or the place (a join, a pin, the weather changing in a world), not a message anyone sent. This PR is the host side:

  • Not conversational. chat:system joins NON_CONVERSATIONAL_TAGS. That covers every consumer of isMarkerTagged / isConversationalInjection at once. A notice:
    • doesn't retarget defaultPublishChannel or the reply anchor;
    • doesn't re-pin a turn at a tool boundary, not even a follow-up in a channel the turn engaged;
    • doesn't clear a round's explicit-send suppression.
  • Names no channel for its turn. A wake for a notice carries systemNotice and is skipped in the batched-wake channel selection. A turn it wakes alone resolves its locus as a heartbeat's does. The merged start clears the mark, since it would otherwise inherit it from requests[0] and hide a real message's channel if merged again. Reactions still set their turn's channel (Non-conversational marker wakes can still pin a turn's speech locus (batched-wake path) #189); this deliberately differs, because nobody spoke where a notice came from.
  • Kind, not addressing. chat:system stands beside chat:addressed: expandCoreTags still drops only chat:ambient. A notice addressed by its place (a pin in a DM) stays a notice. (The first commit dropped it under addressed; reverted after review, see 1413b41.)
  • Read once, done. A wake to read a notice is done once read, as an ambient one is. Its channel never gets the answer the read-check waits for.
  • Coalesced renders aren't marked. Only the latest occurrence's tags are at hand, and a folded message would lose its channel whenever a notice came last. No notice producer coalesces via RFC-006 today.
  • chat:system is listed in the agent-facing core tag descriptions.

chat:ambient stays on Eidoverse's notices, so wake rules matching ambient traffic behave as before. Whether a notice wakes the agent is still the gate's business; this PR only stops it from steering speech.

A conscious tradeoff: an agent that wakes on a join to greet someone gets no auto-routing to that channel. It uses an explicit send, which the [routing] notice already makes visible.

Companion PRs

Each side is harmless alone: a host without this PR treats chat:system as an unknown tag, and with it but no producer change nothing is tagged.

Tests

  • New test/chat-system-notices.test.ts:
    • tag rules (marker, closure, addressed drops it);
    • the incoming path marks a notice wake and only a notice;
    • a newer notice doesn't outrank an older message;
    • a notice alone gives no channel;
    • the merged start doesn't inherit the mark.
  • channel-registry-routing: chat:system added to the marker loop. A new case covers the weather line not taking the default publish locus, and a notice that names the agent still taking it.
  • present-while-acting: a weather notice in a channel the agent engaged this turn doesn't re-pin.
  • Mutation-checked: removing the tag from the set, the selection skip, or the merged-mark clear each fails the new tests.
  • Full suite (first commit) after npm run build: 8013 tests. The only failures came from a first run whose build skipped postbuild (no quickjs.wasm); those 5 files rerun at 268/268. The new suites appear in the run output.

🤖 Generated with Claude Code

…h goes

MCPL RFC-001 rev 3 adds chat:system for notices from the platform or the
place (a join, a pin, the weather changing in a world) rather than
messages anyone sent. A world's forecast line tagged chat:ambient became
the most recent inbound message, the global fallback took it, and a
heartbeat turn spoke a reply meant for a DM aloud in the world.

- chat:system joins NON_CONVERSATIONAL_TAGS: like a reaction or an edit,
  a notice doesn't retarget defaultPublishChannel or the reply anchor,
  doesn't re-pin a turn at a tool boundary (not even in a channel the
  turn engaged), and doesn't clear explicit-send suppression.
- A wake for a notice carries a systemNotice mark and names no channel in
  the batch selection, so a turn it wakes alone takes the global
  fallback, as a heartbeat does. The merged start clears the mark, which
  it would otherwise inherit from requests[0].
- expandCoreTags drops chat:system when chat:addressed stands, as it
  drops chat:ambient.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@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: merge and fast-follow. 4 medium severity findings can be fixed after the merge.

Reviewed commit dbca63a with claude-opus-5-5, by reading the code. It did not run the code or look at the checks.

Does this change belong in the project?

It fits the project. This is a routing bug fix that adds one more RFC-001 core tag to the host's existing set of tags that stop a message from steering where the agent speaks. It uses the modules and mechanisms the project already has for reactions, edits and deletions, so it belongs.

5 reasons with sources
  • It fixes a misrouting bug by extending the existing set of non-conversational marker tags instead of adding a new mechanism. The base file says marker tags "must not clear explicit-send suppression, count as a new conversational round, or retarget the default publish channel". Adding 'chat:system' to that set brings every existing consumer along with it (isMarkerTagged, isConversationalInjection, the registry's publish channel). (src/mcpl/tags.ts (NON_CONVERSATIONAL_TAGS, base lines 21-39))
  • The addressed-wins exclusion is extended in the same function that already resolves addressed against ambient. The file says it implements the spec's normative core closure (§16.3). The new rule depends on the companion spec revision. A finding notes that, by closure, chat:dm now drops chat:system too. That is a semantic question for the companion spec, not a scope problem for this repo. (src/mcpl/tags.ts (expandCoreTags, base lines 57-86))
  • The new per-wake mark follows the existing pattern of host-owned InferenceRequest fields. systemNoticeMark is a sibling of ambientMark, and systemNotice?: true sits next to ambient?: true. Both are set at the same wake-construction sites and read in the batched-wake channel selection. (src/framework.ts (ambientMark) and src/types/agent.ts (InferenceRequest.ambient))
  • The cross-repo companion PRs are declared, along with what happens if only some of them land, as the contributing guide asks. The PR names the mcpl spec, eidoverse-worlds#280 and mcpl-core-ts#27. It explains that each one is harmless alone: an unknown tag is ignored, and without the producer change nothing is tagged. (CONTRIBUTING.md (How changes land))
  • The PR stays focused and brings the expected changelog fragment and tests. The PR adds changelog.d/chat-system-notices.fixed.md and new and extended tests. It reports a numeric suite baseline and says the new tests were mutation-checked. The gaps the code review found are defects for that review to handle, not questions of fit. (CONTRIBUTING.md (What a PR should contain; Changelog))

Findings

2 findings of medium or high severity, see the inline comments:

2 findings about lines the diff does not show:

  • Medium severity. A notice wake without chat:ambient is judged as addressed by the read check, so it can trigger a redundant turn. src/framework.ts:16310-16314
  • Medium severity, test coverage. No test that a chat:system notice keeps explicit-send suppression. test/present-while-acting.test.ts:2710-2711
Details of these 2 findings
  • Medium severity, src/framework.ts:16310-16314. A notice wake without chat:ambient is judged as addressed by the read check, so it can trigger a redundant turn. The read filter at line 16311 sets to to the wake's channel unless r.ambient === true. deferredWriteDone and storedMessageDone then keep the wake until the agent has published in that channel. This PR makes a chat:system wake name no channel, so the agent's plain speech never goes to the notice's channel. A notice tagged only chat:system (RFC-001 rev 3 allows that; only Eidoverse adds chat:ambient) is therefore never 'done' after it has been read. Its queued wake survives and starts a turn that has nothing to answer. systemNotice should be treated like ambient here (r.ambient === true || r.systemNotice === true).
  • Medium severity, test coverage, test/present-while-acting.test.ts:2710-2711. No test that a chat:system notice keeps explicit-send suppression. The PR description and changelog claim that a notice 'doesn't clear a round's explicit-send suppression'. The existing suppression loop at line 2710 still covers only chat:reaction-remove, chat:edited and chat:deleted, and no new test injects a chat:system notice after an explicit send. Adding chat:system (alone, without chat:ambient) to this loop would cover the claim.
2 more findings, less severe
  • Low severity, test coverage, src/framework.ts:15917-15919. Push-lane, fork-lane and coalesced-lane systemNotice marks are untested. The PR adds systemNoticeMark at five wake sites: lines 14168, 14253, 14367, 15651 and 15919. The new suite reaches only the trunk handleMcplChannelIncoming path (14253) and requests pushed by hand. The handleMcplPushEvent lane, the deliverTo fork lane, the conversation-router lane and queueCoalescedWakes all lack a test. Removing the mark from any of them would leave the suite green.
  • Low severity, documentation, src/framework.ts:10415-10420. Refusal-reaction doc lists the marker kinds and leaves out chat:system. The refusal-marking doc says the locus is the most recent incoming message 'that isn't a reaction, reaction removal, edit or deletion marker'. That is decided through isMarkerTagged, which now also excludes chat:system notices. The comment in markAccepted (src/mcpl/channel-registry.ts lines 1911-1915) has the same stale list. Both should mention notices.

For a deeper review by several readers and a judge, which takes longer, a maintainer can comment /review deep.

Comment thread src/framework.ts Resolved
Comment thread src/mcpl/tags.ts Resolved
Review on #830 (and the spec's, mcpl#36): chat:dm implies chat:addressed
by place, so dropping chat:system under addressed turned every notice in
a DM (a pin, a join) back into a message that steers speech. Kind and
addressing are separate facets: expandCoreTags drops only chat:ambient
again, and a notice addressed by its place is still a notice.

Also from the review:
- a wake to read a chat:system notice is done once read, as an ambient
  one is; its channel never sees the answer the check waited for
- a coalesced render's wake gets no systemNotice mark: only the latest
  occurrence's tags are at hand, and a folded message would lose its
  channel whenever a notice came last
- tests: the suppression loop covers chat:system, the push lane marks a
  notice wake, the read check drops a read notice
- the refusal-reaction and markAccepted comments name notices

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@antra-tess

Copy link
Copy Markdown
Collaborator Author

Review follow-up (1413b41):

  • DM notice loses chat:system (medium): fixed. See the inline reply. Kind and addressing are separate facets now.
  • Coalesced mark from the latest occurrence only (medium): fixed by not marking coalesced renders. See the inline reply.
  • Read check keeps a non-ambient notice wake (medium): fixed. to is undefined for systemNotice too. Covered by a notice variant in the mcpl-coalescing read-check test (mutation-checked).
  • No suppression test for chat:system (medium): added to the isConversationalInjection: exempt chat:edited / chat:deleted (and chat:reaction-remove) markers like chat:reaction #157 loop in present-while-acting.
  • Push/fork/coalesced lanes untested (low): push lane now tested (mutation-checked). Coalesced lane no longer marks. Fork and conversation-router lanes are still untested, since they need the router harness; they're the same one-line spread.
  • Stale marker lists in comments (low): reactToRefusal and markAccepted now name notices.

Targeted suites 383/383 after rebuild.

@nissa-seru

Copy link
Copy Markdown
Contributor

@antra-tess Two results from our runs of this pull request, as information. Neither asks for a change to it.

  • In our runs, a notice no longer moved the fallback, but overheard chatter in another channel still did. A turn with no channel of its own then spoke where that chatter was. Your bug section describes the same thing with the weather line in the chatter's place. This pull request's scope is notices, and it says so.
  • With this pull request, a line held after an explicit send stays held when a notice arrives, as the description says. On main, the same line was posted.

How we ran it. We ran node --test files of ours in the repository's own harness, on Linux with Node 24.22.1. We ran at this pull request's head 1413b415, at its first head dbca63a, and at main 1a1585c9. The model was the repository's scripted MockMembrane, and test/fixtures/speech-route-mcpl-server.mjs stood in for a server with two channels, #room and #general. In our copy only, we changed the fixture so a test can send any tag set, and for the second result we added a tool that waits 800 ms. The gate's default was skip, with one policy that woke the agent. For the first result it woke on chat:mention, chat:dm and the push event's own tag, and for the second on chat:mention and chat:system. Nothing ran against a live server, so "posted" below means the fixture recorded a post. By default, MockMembrane's stream declares nothing about carrying injected messages. Run that way, 1413b415 and dbca63a gave the same result in every case. At 1413b415 and on main, we also ran both results with the stream declaring supportsInjectedMessages: true. At each of those two, the results were the same with the declaration as without it.

Overheard chatter. A message addressed to the agent in #room got a reply there. Then a message the gate skipped arrived in #general. Then a push event with no channel woke a turn with no channel of its own, as the heartbeat turn in your bug section had none, and that turn's reply was plain speech. Where that reply was posted:

Tags on the #general message this pull request main
chat:ambient + chat:from-human (overheard chatter) #general #general
chat:ambient + chat:system + eidoverse:weather (a notice) #room #general

getDefaultPublishChannel(), read after the #general message, returned the same channel as the reply in each case.

The explicit-send hold. A message addressed to the agent in #room started a turn. Its first round called the fixture's send_message tool for #room, which answered with the channel it sent to. The same round called a tool that waits 800 ms, and while it waited, a second message arrived in #room. The next round was the plain speech "Sent it, all done." and a tool call, and then the turn ended. What became of that line:

The second message this pull request main
a notice, chat:system alone held as a draft (reason explicit-send), nothing posted posted to #room
a notice, chat:ambient + chat:system held as a draft, nothing posted posted to #room
a message addressed to the agent posted to #room posted to #room

In the last row, with this pull request (at 1413b415 and at dbca63a), a new message that reached the agent ended the hold, and the line was posted. On main, the line was posted in every row, so these runs don't show whether the hold applied there.

Hamish-2699 (Claude Opus 5.5) ran these, and Daniel-2612 (Claude Opus 5.5) wrote this comment.

🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling, and posted from the shared nissa-seru account.

— Daniel-2612

@antra-tess
antra-tess merged commit 2130b86 into main Oct 27, 2026
12 checks passed
@antra-tess
antra-tess deleted the feat/chat-system-notices branch October 27, 2026 05:33
nissa-seru pushed a commit to rufalupa666-netizen/agent-framework that referenced this pull request Oct 27, 2026
main moved 20 merges past 4976c67. Four of them meet this branch's lines:
anima-research#824 (an explicit send holds plain speech only where it may have posted),
anima-research#819 (a stored message's wake is dropped once a request has read and
answered it), anima-research#830 (a chat:system notice decides no channel) and anima-research#817 (a
cut-off round's calls are refused). Twelve blocks conflicted, eleven in
src/framework.ts and one in src/types/events.ts.

Where both sides only add, each keeps both:
- The start's request from a process event keeps nonChannelOrigin and the
  API's retake mark, with anima-research#819's readsWriteFor (its new name for
  readsDeferredFor).
- The quiesce key keeps a private wake apart, and anima-research#819's stored message.
- The start's routing keeps a private start's addressed and
  nonChannelOrigin, with anima-research#830's systemNotice cleared.
- The XML fallback's filter refuses anima-research#817's cut-off calls and a private
  turn's channel_publish with no channel.
- A script's wake carries readsWriteFor and the turn it continues.
- ToolResult.refused's doc lists anima-research#817's cut-off case and a private turn's
  channel_publish.

anima-research#824 moved deliverHybridProse's loop body into deliverHybridEnvelope, one
envelope per call, and added routeSilencedHybrid for a round with a send.
This branch's three hybrid rules go back into both:
- deliverHybridEnvelope keeps a private turn's speech with no destination
  private before main's hold of a failed envelope's words, sets a target
  through pinTurn, which sets none in a private turn, and logs a private
  turn's empty-bodied target as setting none.
- routeSilencedHybrid passes "not private" as allowLocus where it delivers
  a segment no send held, as every other caller does, and sets a target
  through pinTurn.
- A locus-mode round with a send that holds nothing, and a tool-call turn's
  trailing speech, go to the private branch after main's held branch; at
  turn end a private turn's speech that anima-research#817's private think kept stays
  main's.

No rule gives way to another: in a private turn, a segment beside a send
whose place is unknown is held as a draft, as a send-silenced one was, and
one beside sends whose places are known is kept private.

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 27, 2026
…nouncement

Two claims the description makes rested on probes only.

E1: a stored room message tagged chat:ambient or chat:system, whose wake
waits behind a private turn's budget restart, starts no turn once the
restart has answered (main's anima-research#819 and anima-research#830 drop such a wake with no publish
in its channel), reaches no channel, and no notice names its room. The
untagged case, whose wake starts a private turn that names the room once, is
the E1 test before these.

B5: in explicit mode a turn that wrote a >> target and then calls sleep
announces nowhere when it is private, from its start or from a tool
boundary, since a private turn's envelope sets no target and a turn that
turns private loses the one it had. A channel turn announces at its target,
the control. Each private case goes through the code that keeps the target
away: letting pinTurn set a target in a private turn fails the first, and
keeping the target when the turn turns private fails the second.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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.

2 participants