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