Skip to content

fix(agent-channel): preserve unaddressed continuation paragraphs in single-recipient messages#11

Open
evannadeau wants to merge 1 commit into
SpawnBox-dev:mainfrom
evannadeau:fix/agent-channel-single-recipient-paragraph-routing
Open

fix(agent-channel): preserve unaddressed continuation paragraphs in single-recipient messages#11
evannadeau wants to merge 1 commit into
SpawnBox-dev:mainfrom
evannadeau:fix/agent-channel-single-recipient-paragraph-routing

Conversation

@evannadeau
Copy link
Copy Markdown

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.ts line 608 (pre-fix):

function filterParagraphsForReceiver(
  content: string, receiverId: string, sender: SessionEntry, sessions: SessionEntry[],
): string | null {
  const paragraphs = content.split(/\n{2,}/);
  const kept: string[] = [];
  for (const para of paragraphs) {
    const paraAddr = parseAddressing(para, sender, sessions);
    if (paraAddr.targets.includes(receiverId)) {
      kept.push(para);
    }
  }
  return kept.length > 0 ? kept.join("\n\n") : null;
}

Splits content on blank lines, runs parseAddressing per 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:

@SA-<id8> dispatch reviewer on PR #72. Fix scope to surface in the brief:

- Patch 3 regex must match current upstream shape
- Multi-patch non-transactionality requires read-then-validate-all-then-apply
- `--help` sed range must terminate at header end

Same branch, no new ADR, re-review through reviewer chain.

The receiving SA's filewatcher reports the message arrived as:

@SA-<id8> dispatch reviewer on PR #72. Fix scope to surface in the brief:

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.

const targetSets = paraAddrs
  .filter((a) => a.targets.length > 0)
  .map((a) => [...a.targets].sort().join(","));
const allAddressedParagraphsShareOneTargetSet =
  targetSets.length > 0 && targetSets.every((s) => s === targetSets[0]);

if (allAddressedParagraphsShareOneTargetSet) {
  return paraAddrs.some((a) => a.targets.includes(receiverId)) ? content : null;
}
// else: per-paragraph filtering fallback (the original behavior)

What this changes (cases that now deliver in full)

@SA-X dispatch ... 

- bullet 1
- bullet 2

closing prose

→ All 3 paragraphs reach SA-X. Common case, now fixed.

@SA-X first directive

@SA-X also-for-X second directive

bullets

→ All 3 paragraphs reach SA-X (single-recipient-set since both addresses go to {X}).

@SA-X,@SA-Y joint directive

bullets

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

private prose to user

@SA-X directive

private prose to user

@SA-Y different directive

→ 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 at tests/integration/agent_channel_routing.test.ts:210 continues to pass — verified.

intro prose (no address)

@SA-X directive

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

unaddressed-only message

→ 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 install clean (98 packages).
  • bun run builddist/server.js 0.94 MB, 249 modules (regenerated to match source).
  • bun test517 pass / 0 fail / 1214 expect() calls (up from 516 / 1207).
  • New test added at 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.
  • Existing mixed-audience test (line 210) continues to pass: two different @SA-<id8> addresses → different target sets → per-paragraph filter applies → private paragraphs filtered out.
  • Existing unaddressed-message test (line 289) continues to pass: no @-addresses at all → no paragraphs match → returns null → no SA delivery.

Files changed

  • plugins/orchestrator/mcp/engine/agent_channel.tsfilterParagraphsForReceiver reshaped 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 via bun run build.

Related

🤖 Generated with Claude Code (Admiral PA orchestrating an upstream-cleanup batch)

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