fix(agent-channel): preserve unaddressed continuation paragraphs in single-recipient messages - #11
Closed
evannadeau wants to merge 1 commit into
Conversation
…ingle-recipient messages The per-paragraph routing filter in `filterParagraphsForReceiver` (agent_channel.ts:608, added in 0.30.22 / work_item b4c37849) splits content on blank lines and only delivers paragraphs that contain an `@SA-<id8>` address. The intent — prevent PA's user-private prose from leaking to SAs in a mixed-audience message — is sound. The empirical consequence is over-aggressive: a sender writing a directive with ordinary structure (intro paragraph with @-address, then bulleted scope, then closing prose) sees only the intro paragraph reach the receiver. Everything from the first blank line onward gets dropped. This affects every multi-paragraph PA→SA dispatch and has been observed recurring across multiple operator sessions. Workaround until now: senders collapse everything to one dense paragraph with semicolons or inline numbering, which is unreadable for the operator watching the channel. Fix: single-recipient-set heuristic. When ALL @-addressed paragraphs in a message route to the same target set, deliver the WHOLE content to that target set (no paragraph filtering). When @-addressed paragraphs route to DIFFERENT target sets, fall back to per-paragraph filtering — preserving the original safety property for genuinely mixed-audience messages. The existing integration test at `tests/integration/agent_channel_routing.test.ts:210` (mixed-audience private-prose-must-not-leak) still passes — it has @-addresses to two DIFFERENT SAs, so the per-paragraph fallback applies and the private paragraphs continue to be filtered out. Added a new integration test covering the single-recipient case (intro + bullets + closing → whole message delivered). Test suite: 517 pass / 0 fail / 1214 expect() calls (up from 516 / 1207). `bun build` regenerates dist/server.js cleanly (249 modules, 0.94 MB).
6 tasks
Author
|
Closing on our side: we've moved to a single-orchestrator model and retired the PA/SA agent-channel layer, so we no longer use this surface and won't be maintaining this PR. The fix itself remains valid for anyone running the agent-channel — the diff stays here if a maintainer wants to pick it up. Thanks! |
SpawnBox-dev
pushed a commit
that referenced
this pull request
Aug 9, 2026
… could not surface at all (0.49.0) The hybrid search could not return a note the keyword leg had missed, however good its cosine. So the chunking and model work shipped hours earlier in 0.46-0.47 was being discarded one stage downstream. MEASURED on the live 7148-note KB, tracing one probe end to end. Target cc1d3816, query "chatter about a topic got mistaken for the real event and corrupted the label": vector similarity ......... #2 of 7148 <- second-best match in the corpus after RRF ................. #11 after signal/confidence ... #19 of 24 candidateTopK slice(0,12) . DROPPED final result .............. ABSENT from the top 6 TWO STRUCTURAL SUPPRESSORS, neither wrong in isolation: 1. RRF DUAL-CONTRIBUTION ASYMMETRY. reciprocalRankFusion sums 1/(k+rank) over both lists, so a note present in BOTH can reach ~0.033 while a vector-only note is capped at 1/(60+1) = 0.0164 - and only the ~18 FTS candidates are eligible for the bonus. No cosine score can lift a note the keyword leg missed above a mediocre note it found. Amplified by list-length imbalance: 18 keyword candidates against 7148 vector-ranked notes. 2. THE SIGNAL BOOST IS AN ABSORBING STATE - the same class this lane already found in briefing ordering (ed316fcd entry R). signal is EARNED BY BEING SURFACED, so a note that has never surfaced cannot earn the multiplier that would let it surface. Observed while writing these tests: a filler with signal 50 outranked a note that won on BOTH keyword and vector. THE FIX: reserve at most 2 result slots for the highest-cosine notes. Displaces from the TAIL, never the head, so a note both signals agree on is never evicted. Reserved candidates pass the same superseded / code_ref filters as any other result - a reserve that bypassed them would be a back door around the caller's constraints. Direct precedent, same shape and same remedy: GLOBAL_RESERVED in recall.ts, whose comment reads "without reserved slots, the larger project DB drowns them out". The vector leg was the drowned minority list here. VERIFIED LIVE: cc1d3816 now surfaces. HONEST LIMIT, stated rather than left to be discovered: probes ranking #10, #60 and #129 by vector still do NOT surface. A 2-slot reserve rescues the head of the semantic distribution, not its tail. THE DEEPER REWORK IS DEFERRED ON PURPOSE, filed as 27d1da01 with five candidate directions and none prescribed. The blocker is a LABELLED EVALUATION SET, not implementation effort: this same session tried mean-centering - the textbook anisotropy fix - and measured it WORSE on every probe. Five hand-written adversarial probes can DETECT a broken ranker; they cannot TUNE one, and changing live fusion without an eval set is a coin flip that feels like progress. Guards: tests/engine/semantic-reserve.test.ts, 7 tests - a zero-keyword-overlap note surfacing against 30 lexically-matching signal-80 decoys; the reserve capped at 2 of 6 so keyword keeps the majority; the top hybrid result not evicted; a superseded note never promoted; no-vector queries still returning keyword results; plus wiring assertions on the bound and on tail-displacement. Suite 1125 pass / 0 fail. One fixture correction recorded in the test file rather than made quietly: the head-preservation test originally gave its decoys signal 50 and failed - not because of the reserve, but because the pre-existing boost let a high-signal filler outrank a note winning on both signals. Decoys are now signal 0 so the test measures what its name claims; the boost finding went to 27d1da01. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MD4kPkrZLWbUxe4arhdwii
SpawnBox-dev
pushed a commit
that referenced
this pull request
Aug 11, 2026
…sed, and a test a comment could pass was hiding a stale base rate SA-d4db6493 nominated this as worth more than any note reorganisation, after being the SECOND first-person report of the same fork. Confirmed against both sources before touching anything. === THE FORK, and it is the alert's fault rather than the reader's Note e24d8156 is the liveness-triage method. Firing #11 (2026-08-11) recorded a reader skipping its two most important steps. Reading the alert text explains why: BOTH OMISSIONS WERE PRESCRIBED BY THE ALERT. * The alert said "sample twice a few seconds apart". Firing #10 had already MEASURED a 20-second frozen window on a session that was provably running - it was executing the command doing the measuring - and revised the method to three-plus points across 40-60s with an INDEPENDENT control. The alert never got that revision. The reader followed the alert and got a right answer from an instrument that could not have told them they were wrong. * "Did they post to the channel after the alert?" - free, decisive, and the reader already holds the messages - was the THIRD bullet, framed as one branch among several. It is now first, and says why it gets skipped: it does not FEEL like evidence because it is not a measurement, and it outranks one. Two readers with the method note available followed the alert instead (firings #8 and #11). A method note cannot compete with the instructions printed at the moment of the alarm. === THE SIBLING THAT NEVER GOT THE FIX The EGRESS alert hardcoded "BASE RATE: 0 of the last 8 firings" - stale (the note records 0 real faults in 11). The INGRESS alert had already removed exactly this at 0.34.0 with the reasoning written out: "a stated base rate that can go stale is worse than no stated base rate - it carries the authority of a measurement with the durability of a comment." That lesson was applied to one alert and never to its sibling. Same shape as the step-1 gap, and the same shape as a5f4f5a1's grep sweep: the fix landed where attention was and stopped there. Replaced with the calibration stated qualitatively plus a pointer to the note for the live count. Deliberately NOT re-derived as a number - any number printed in an alert rots the moment the next firing lands. === A TEST THAT A COMMENT COULD PASS Found while fixing the above. The guard read RAW SOURCE - comments included - and sliced a fixed 2600 characters from the marker. Two failures: 1. Adding rationale comments inside the alert pushed real text out of the window, failing an assertion about content that was still present. 2. Worse: when the hardcoded base rate was removed, the "prints the base rate" test KEPT PASSING, because the phrase survived in the comment explaining its removal. The assertion was satisfied by prose no reader will ever see. A test that a comment can pass is not testing the alert, it is testing the file. Now strips comment lines and anchors to the end of the template rather than counting characters. One assertion changed from pinning a literal count to asserting the reader gets calibrated - the intent 0.44.1 actually had - plus a new regression test that FAILS if any "N of the last M" is reintroduced. Suite 1249 pass / 0 fail (the orient-auto-retro flake did not fire this run). Typecheck clean. Runtime reports 0.67.0. Not live-verified: the fleet runs 0.64.0 (62d10892 - 0.65.0 never propagated), so this text will not reach a reader until a reload. Refs e24d8156, ed971934 (the open thread that proposed this three days ago), a5f4f5a1. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MD4kPkrZLWbUxe4arhdwii
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The per-paragraph routing filter introduced in 0.30.22 (work_item b4c37849) over-aggressively drops paragraphs from PA→SA dispatches. A directive with ordinary structure — intro paragraph containing the
@SA-<id8>address, followed by bulleted scope and closing prose — delivers ONLY the intro paragraph to the receiver. The bullets and closing get silently filtered out at the receiver's filewatcher.This PR fixes the over-filter without breaking the original safety property (mixed-audience messages must not leak private user-prose to addressed SAs).
The bug, concretely
mcp/engine/agent_channel.tsline 608 (pre-fix):Splits content on blank lines, runs
parseAddressingper paragraph, keeps only paragraphs whose addressing includes the receiver. Continuation paragraphs (bullet lists, follow-up sentences, closing prose) typically don't contain their own@SA-<id8>address — they're scope for the address in the prior paragraph. So they get dropped.Empirical reproduction (multiple operator sessions):
A PA dispatches to an SA:
The receiving SA's filewatcher reports the message arrived as:
That's the entire delivered content. The bullets and the closing are gone. The receiver has no idea what the actual fix scope is — they have to ask for a resend. In practice operators have been working around this by collapsing everything to a single dense paragraph with semicolons or inline numbering, which is unreadable for the operator watching the channel and brittle to write.
Why the existing filter exists (per the comment at the call site, lines 538-548 in the pre-fix file): a sender mixing user-private prose with an
@SA-<id8>directive in one message must not leak the private prose to the addressed SA. The per-paragraph filter is the defense for that. Sound intent; over-aggressive implementation.The fix — single-recipient-set heuristic
When ALL
@-addressed paragraphs in a message route to the same target set, deliver the whole content (no paragraph filtering). When addressed paragraphs route to different target sets (a genuinely mixed-audience message), fall back to per-paragraph filtering — preserving the original safety property.What this changes (cases that now deliver in full)
→ All 3 paragraphs reach SA-X. Common case, now fixed.
→ All 3 paragraphs reach SA-X (single-recipient-set since both addresses go to {X}).
→ Both paragraphs reach both SA-X and SA-Y (single-recipient-set since {X,Y} is consistent).
What this preserves (cases that still apply per-paragraph filtering)
→ Different target sets ({X} vs {Y}). Per-paragraph filtering applies. SA-X gets only
@SA-X directive. SA-Y gets only@SA-Y different directive. Private paragraphs filtered out for both. The existing integration test attests/integration/agent_channel_routing.test.ts:210continues to pass — verified.→ Single-recipient-set ({X}). Whole message to SA-X — INCLUDING the leading "intro prose". This is a behavior change for leading unaddressed prose. Senders who genuinely want it private can write it as a separate message (no
@-addresses → no SA routing happens, original behavior).→ Same as before: no @-addressed paragraphs, falls through to per-paragraph filtering, which keeps nothing for any SA. PA still observes (PA bypasses this function entirely).
Trade-off acknowledged
Senders mixing single-SA dispatch with trailing operator-only prose in the same message will now see that operator-only prose leak to the SA. The recommended discipline (and what operators were doing anyway): send operator-only updates as a separate message. The cost of this regression is much smaller than the cost of the truncation it fixes.
Test plan
bun installclean (98 packages).bun run build→dist/server.js0.94 MB, 249 modules (regenerated to match source).bun test517 pass / 0 fail / 1214 expect() calls (up from 516 / 1207).tests/integration/agent_channel_routing.test.ts— "single-recipient message with unaddressed continuation paragraphs delivers in full" — exercises the exact bug pattern (intro with@SA-<id8>, three bullet paragraphs, closing prose) and asserts the receiving SA gets the whole content.@SA-<id8>addresses → different target sets → per-paragraph filter applies → private paragraphs filtered out.Files changed
plugins/orchestrator/mcp/engine/agent_channel.ts—filterParagraphsForReceiverreshaped with the single-recipient-set heuristic + comment block documenting the trade-off.plugins/orchestrator/tests/integration/agent_channel_routing.test.ts— added single-recipient-with-continuation test.plugins/orchestrator/dist/server.js— regenerated viabun run build.Related
mcp/server.tsfixes)🤖 Generated with Claude Code (Admiral PA orchestrating an upstream-cleanup batch)