Repository navigation
mcpl: a chat:system notice never decides where an agent's plain speech goes - #830
Conversation
…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>
There was a problem hiding this comment.
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:
- Medium severity. A coalesced render's systemNotice mark comes from the latest occurrence only, unlike the all-notices rule used for ambient.
- Medium severity. chat:dm drops chat:system, so a platform notice in a DM becomes an addressed message.
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 setstoto the wake's channel unlessr.ambient === true.deferredWriteDoneandstoredMessageDonethen keep the wake until the agent has published in that channel. This PR makes achat:systemwake name no channel, so the agent's plain speech never goes to the notice's channel. A notice tagged onlychat:system(RFC-001 rev 3 allows that; only Eidoverse addschat:ambient) is therefore never 'done' after it has been read. Its queued wake survives and starts a turn that has nothing to answer.systemNoticeshould be treated likeambienthere (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 onlychat:reaction-remove,chat:editedandchat:deleted, and no new test injects achat:systemnotice after an explicit send. Addingchat:system(alone, withoutchat: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 addssystemNoticeMarkat five wake sites: lines 14168, 14253, 14367, 15651 and 15919. The new suite reaches only the trunkhandleMcplChannelIncomingpath (14253) and requests pushed by hand. ThehandleMcplPushEventlane, thedeliverTofork lane, the conversation-router lane andqueueCoalescedWakesall 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 throughisMarkerTagged, which now also excludeschat:systemnotices. The comment inmarkAccepted(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.
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>
|
Review follow-up (1413b41):
Targeted suites 383/383 after rebuild. |
|
@antra-tess Two results from our runs of this pull request, as information. Neither asks for a change to it.
How we ran it. We ran 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:
The explicit-send hold. A message addressed to the agent in #room started a turn. Its first round called the fixture's
In the last row, with this pull request (at 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 — Daniel-2612 |
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>
…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>
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>
The bug
A world's weather line arrived as
channels/incomingtaggedchat: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, anddefaultPublishChannelbecame the world. The next heartbeat turn had no channel of its own, soresolveLocustook 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:chat:systemjoinsNON_CONVERSATIONAL_TAGS. That covers every consumer ofisMarkerTagged/isConversationalInjectionat once. A notice:defaultPublishChannelor the reply anchor;systemNoticeand 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 fromrequests[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.chat:systemstands besidechat:addressed:expandCoreTagsstill drops onlychat: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.)chat:systemis listed in the agent-facing core tag descriptions.chat:ambientstays 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:systemas an unknown tag, and with it but no producer change nothing is tagged.Tests
test/chat-system-notices.test.ts:channel-registry-routing:chat:systemadded 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.npm run build: 8013 tests. The only failures came from a first run whose build skippedpostbuild(noquickjs.wasm); those 5 files rerun at 268/268. The new suites appear in the run output.🤖 Generated with Claude Code