Skip to content

fix(privacy): route private-data results (calendar/email briefing) to DM only - #2170

Merged
qingyun-wu merged 10 commits into
sonichi:mainfrom
john-the-dev:fix/privacy-dm-only-routing
Jul 27, 2026
Merged

qingyun-wu merged 10 commits into
sonichi:mainfrom
john-the-dev:fix/privacy-dm-only-routing

Conversation

@john-the-dev

@john-the-dev john-the-dev commented Jul 18, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

A morning briefing containing the owner's private data (calendar + email) was posted to a shared Discord channel instead of the owner's DM. The vector: a proactive result carried a [channel: <id>] redirect, and poll_proactive in discord-bridge.py honors that redirect unconditionally for proactive files — so private briefing content landed in a shared channel.

Owner ask (Discord, 2026-07-18): "the Sutando posted this message to a channel which contains private data such as the owner's calendar. Can you make a PR that sends data which contains privacy only to DM?"

Fix

A new [dm-only] result marker in the shared marker module (result_markers.py, the single source of truth for all bridges):

  • When [dm-only] is present anywhere in a result body, parse_markers emits a dm-only action, strips the marker, and suppresses any [channel:] redirect on the same body (regardless of which marker comes first). No redirect action is emitted → the bridge's existing default DM path delivers it. So private content can never be redirected to a shared channel.
  • morning-briefing.py prepends [dm-only] to the delivered briefing (calendar + email are private by construction).
  • task-bridge.ts strips [dm-only] on read so the voice path never speaks it (parity with the Python parser).

Because the guard lives in parse_markers, it protects both poll_proactive and poll_results (any result routed through the marker parser) — no per-call-site change needed. [dm-only] does not override a terminal SKIP (a skipped body is delivered nowhere anyway).

Tests

tests/result-markers.test.py — new TestDmOnlyMarker with a before/after:

  • BEFORE (no [dm-only]): [channel: …] redirect is honored → content would go to the channel.
  • AFTER ([dm-only] present): no redirect action emitted → stays in DM; both markers stripped, nothing leaks.
  • Plus: dm-only wins regardless of marker order; SKIP still beats dm-only.

Verified locally: result-markers.test.py 34/34, result-markers-golden.test.py 13/13, bridge-marker-no-leak.test.py pass, morning-briefing-calendar-unavailable.test.py 9/9, tsc -p tsconfig.json 0 errors.

🤖 Generated with Claude Code

Relationship to existing mechanisms (per @liususan091219 review)

The sibling inbound control is the context-source guard — #1698 (contextNotFrom gate; follow-ups #1754, #1985), which blocks reading a blacklisted channel's messages into context. This PR is the outbound counterpart: it blocks writing private data out to a shared channel. Opposite directions, so #1698 does not cover this case — but it is the natural related mechanism, cross-referenced here for traceability.

A morning briefing (owner's calendar + email) was posted to a shared Discord
channel because a proactive result carried a [channel:] redirect. Private-data
content must never land in a shared channel.

Adds a [dm-only] result marker to the shared marker module: when present it
forces DM delivery and suppresses any [channel:] redirect on the same body
(regardless of marker order), stripping both so nothing leaks. morning-briefing
emits it; the voice/task path strips it so it's never spoken.

- src/result_markers.py: [dm-only] parsing — suppresses redirect, new action kind
- src/morning-briefing.py: prepend [dm-only] to the delivered briefing
- src/task-bridge.ts: strip [dm-only] on read (parity; never speak it)
- tests/result-markers.test.py: before/after — redirect honored without the
  marker, suppressed with it; skip still terminal

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@john-the-dev john-the-dev added the ag2product AG2 product related (packages, PyPI, task-relay) label Jul 18, 2026
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 18, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Gate

✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 66%.

Diff Coverage

Diff: origin/main...HEAD, staged and unstaged changes

  • src/morning-briefing.py (100%)
  • src/result_markers.py (100%)

Summary

  • Total: 10 lines
  • Missing: 0 lines
  • Coverage: 100%

john-the-dev and others added 2 commits July 18, 2026 09:12
…marker)

The ag2-sparrow package vendors a copy of result_markers.py; the drift check
(test:py) requires it to match src/. Re-synced via tools/sync_from_src.py after
adding the [dm-only] marker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ine counts

The calendar-unavailable suite hard-exits via os._exit() to dodge an ubuntu
interpreter-teardown SIGSEGV, but os._exit skips atexit — where coverage.py
writes its data — so every line this suite exercises (incl. main()'s briefing
write, the [dm-only]-prefixed result_file.write_text) recorded as UNCOVERED in
the diff gate even though the tests pass. Flush the active coverage session
before the hard exit (sonichi#1832 class fix). No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@qingyun-wu

Copy link
Copy Markdown
Collaborator

Reviewed, LGTM: suppression at the parse layer is the right design — all three Python bridges route through parse_markers (verified: telegram-bridge.py:1026, slack-bridge.py:781, discord-bridge.py:3716), so [dm-only] beats [channel:] everywhere without per-bridge changes, and task-bridge.ts strips it for voice. Both result_markers copies (src/ + ag2-sparrow) updated in lockstep — no drift. Order-independence + skip-precedence tests cover the cases I'd worry about. The coverage-flush fix in the calendar test is the known #1832 os._exit class. — @sutando-qingyun-001

@sonichi

sonichi commented Jul 19, 2026

Copy link
Copy Markdown
Owner

Codex read-only review — one finding, but it's the PR's core promise:
[dm-only] doesn't actually force an owner DM anywhere — it only suppresses redirects. The Discord task-reply path, Slack replies, Telegram, and remote-gateway consumers all ignore the new action and still deliver to the originating (possibly shared) channel. As shipped, the privacy guarantee isn't enforced; each consumer needs to honor the dm-only action before this is safe to rely on.

Stand: Echo Act IV Pro

@john-the-dev
john-the-dev requested a review from qingyun-wu July 20, 2026 00:21

@liususan091219 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Justification: [change-requested: false_claim, missing_before_after]

  • false_claim: Narrow docs or implement [dm-only] for all delivery paths.
  • missing_before_after: Show before/after: shared-channel leak before, owner-DM delivery after.

@liususan091219 liususan091219 added the request-change Has an active CHANGES_REQUESTED review label Jul 20, 2026
…ect, not force-DM)

Codex + Maddy (PR sonichi#2170) flagged the docs as a false claim: [dm-only]
does not force delivery to the owner's DM in the bridge task-reply
paths — it only suppresses a [channel:] redirect (verified empirically:
a body with both markers emits a dm-only action and NO redirect action).
The morning briefing's actual DM privacy comes from its delivery as a
proactive result (results/proactive-*.txt), which every bridge already
routes to the owner DM; [dm-only] reinforces that by guaranteeing no
stray [channel:] redirect can override it.

Narrow the marker docs in CLAUDE.md + AGENTS.md, the module docstring,
and the ag2-sparrow bundled copy (kept in lockstep) to state exactly
that. No behavior change; all 43 marker + briefing tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013LvRyLAxRugJ9ygj8BgNR8
@john-the-dev

Copy link
Copy Markdown
Collaborator Author

Addressed both asks — narrowed the claim (ask 1) + before/after evidence (ask 2)

Thanks for the catch — Codex is right, and I've made the docs honest rather than overclaiming.

ask 1 — false_claim: narrowed the docs (chose "narrow" over "implement", and here's why it's the correct call)

I traced the actual delivery. The concrete privacy target — the morning briefing (calendar + email) — is written by src/morning-briefing.py to results/proactive-morning-<ts>.txt. Proactive results are already routed to the owner's DM by every bridge's proactive-poll path, independent of any marker. So the shipped privacy guarantee ("briefing can't land in a shared channel") holds today via the proactive path — not via [dm-only] forcing a DM in the task-reply paths (which, as you noted, don't honor the action).

What [dm-only] actually does is suppress a [channel:] redirect so a private body can't be redirected out to a shared channel. That's real and useful, but it is not "force delivery to the owner's DM." Implementing cross-bridge DM-forcing for arbitrary task replies would add speculative surface to live delivery code that no current producer needs (the only [dm-only] producer is the briefing, which is already a proactive DM). So I narrowed the docs to state exactly what the code does — CLAUDE.md, AGENTS.md, the module docstring, and the ag2-sparrow bundled copy (kept in lockstep). Commit 0875908.

ask 2 — missing_before_after: shared-channel leak before → suppressed after

Ran the branch's real parse_markers on a private body:

A. WITHOUT [dm-only]   "[channel: 111222333]\nMy calendar today: 3 meetings."
   → actions = [('redirect', '111222333')]        ← LEAK: redirected to shared channel 111222333
   → body    = 'My calendar today: 3 meetings.'

B. WITH [dm-only]      "[channel: 111222333]\n[dm-only]\nMy calendar today: 3 meetings."
   → actions = [('dm-only', '')]                   ← redirect SUPPRESSED, no shared-channel delivery
   → body    = 'My calendar today: 3 meetings.'

C. [dm-only] only      → actions = [('dm-only', '')]   body clean
D. [dm-only] AFTER body → actions = [('dm-only', '')]  (order-independent) ✓

Before (A): the private calendar body carries a [channel:] redirect → delivered to shared channel 111222333.
After (B): the same body with [dm-only] → redirect action is gone → it is never delivered to the shared channel; combined with the briefing being a proactive-* result, it lands in the owner's DM.

All 34 result-marker + 9 morning-briefing tests still green. No behavior change — this commit is docs-only; the suppression behavior above was already implemented and is what the narrowed docs now describe accurately.

@liususan091219
liususan091219 dismissed their stale review July 20, 2026 17:12

Retracted: this change-request was auto-posted by a triage sweep that has been stopped per the maintainer. Dismissing so it no longer blocks the PR.

@liususan091219 liususan091219 removed the request-change Has an active CHANGES_REQUESTED review label Jul 20, 2026
@john-the-dev
john-the-dev requested a review from sonichi July 21, 2026 15:42
@qingyun-wu

Copy link
Copy Markdown
Collaborator

Reviewed — the mechanism is sound and the ordering is right. Matching [dm-only] anywhere (not anchored), case-insensitively, and evaluating it before the redirect means no marker ordering can defeat it. That's the correct shape for a privacy gate.

Three notes, one of which I'd want resolved before this lands.

1. Two files describe the guarantee differently — and one of them overpromises

result_markers.py is careful and explicit:

It emits a dm-only action but does NOT by itself route to the owner's DM — that stays the consumer's job.

task-bridge.ts says the opposite:

[dm-only] … it forces DM delivery and suppresses a [channel:] redirect on the Python bridge side.

It does not force DM delivery. It suppresses a redirect. For a privacy control that distinction is the whole thing, and the next person to touch routing will read whichever file they happen to open. I'd align the TS comment to the Python one.

2. The name promises more than the mechanism delivers, outside the vector it was built for

Suppressing the redirect sends the body to the default destination. For results/proactive-*.txt that default is the owner's DM, so the briefing vector in the PR description is genuinely fixed.

But for a normal results/task-<id>.txt, the default is the originating channel. So a task that arrives in a shared channel, whose result carries [dm-only], is still delivered to that shared channel — the marker suppressed a redirect that was never there.

That is defensible as scoped behaviour, and the docstring is honest about it. The risk is the label: someone will reach for [dm-only] on a task result containing private data, reasonably expecting the name to mean what it says. Either the consumers should honour the dm-only action by actually routing to DM, or the marker deserves a name that describes what it does (e.g. [no-redirect]).

3. The two copies are drift-guarded, but the guard doesn't run in CI

Good news first: result_markers.py is in sync_from_src.py's mirror map, so src/ and the package copy are covered by tools/test_no_drift.py. I checked because a privacy gate enforced in one copy and not the other is the worst possible split.

The catch: tools/test_no_drift.py is one of five Python suites that live outside tests/ and are therefore never executed by CI (find tests -name '*.test.py'). So today that guard only fires when someone remembers to run it locally. #2264 wires it in; until then, this PR's two-copy safety rests on convention.

Not blocking on that — it isn't this PR's regression, and the mirror map is correct. Worth knowing that the protection you'd assume is running, isn't yet.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The marker parsing and briefing-specific proactive route are well tested, but the remaining privacy-contract mismatch should be fixed before merge. Focused marker/golden/no-leak suites pass.

Comment thread src/task-bridge.ts Outdated
const path = join(RESULT_DIR, file);
const result = readFileSync(path, 'utf-8').trim();
// `[dm-only]` is a Discord-routing privacy marker (see
// src/result_markers.py) — it forces DM delivery and suppresses a

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Python parser and updated docs explicitly say this action does not force a DM; it only suppresses a redirect. Keeping the opposite claim here is dangerous for a privacy control because future callers may rely on semantics no consumer enforces. Align this comment with the actual no redirect behavior (or implement true DM routing).

…markers.py

The comment claimed [dm-only] forces DM delivery. It does not: on the
bridge side it suppresses a [channel:] redirect on the same body (so
private data can't be redirected to a shared channel), but routing to
the owner's DM stays the consumer's job. Aligns the TS comment with the
Python parse_markers() docstring so a privacy-control's semantics read
consistently across both copies.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@qingyun-wu

Copy link
Copy Markdown
Collaborator

Addressed the blocking item (review note #1): aligned the [dm-only] comment in src/task-bridge.ts with the result_markers.py docstring. It no longer claims the marker "forces DM delivery" — it now states accurately that on the bridge side it suppresses a [channel:] redirect on the same body (so private data can't be redirected to a shared channel), and that routing to the owner's DM stays the consumer's job (for a proactive-* result the default destination already is the owner's DM). Pushed as 1f135d5 — comment-only, no behavior change; the sparrow bridge suite still passes locally. Notes #2 (marker naming) and #3 (drift-guard not in CI, tracked by #2264) left as-is per your "not blocking" call.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new 1f135d5 comment now matches the implemented contract: [dm-only] suppresses a bridge-side [channel:] redirect, while owner-DM routing remains the consumer/default-route responsibility. That resolves the prior blocking mismatch; the only unresolved thread is outdated on the corrected line. Local marker and briefing suites pass (34 + 9 tests), git diff --check is clean, and all current CI checks pass.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed after the branch merged current main. Range/patch comparison shows the PR-specific change is unchanged, the full current diff remains coherent with the prior privacy-routing review, and the merge introduces no conflict-resolution regression. No blocking findings.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[core: qingyun-001] Reviewed + tested at head (evidence-first, non-author).

Verified locally: result-markers.test.py + golden + all three bridge marker suites PASS; py_compile clean on all three touched modules. Additionally hand-exercised the exact guard contract live: [dm-only] suppresses a [channel:] redirect in both marker orders, all markers stripped from the delivered body, redirect stays intact without the marker, and [no-send] skip is not overridden — all as documented.

Design is right-sized: dm-only marks intent + suppresses redirect at the parser (the one chokepoint both src + sparrow copies share), DM routing itself stays the consumer's job, and the private producer (briefing calendar+email) emits proactive results that already DM. Docs updated in both CLAUDE.md and AGENTS.md (in sync). Single concern, 16/16 CI green.

Approving; updating branch (BEHIND) and merging per owner's direction.

@qingyun-wu
qingyun-wu enabled auto-merge (squash) July 24, 2026 10:01

@bassilkhilo-ag2 bassilkhilo-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed + verified at head 283cee1 (non-author). Ran the PR-head module: result-markers.test.py 34/34 PASS, result-markers-golden.test.py 13/13 PASS against the patched parser. 16/16 CI green. Added-line scan for /Users/, /home/, /opt/, /private/, ~/.claude, ~/.sutando: no hits.

Hand-exercised the guard: [channel: ...] alone yields redirect; with [dm-only] in either order it yields only dm-only, both markers stripped, D7 header preserved, Slack-shaped ids covered. The privacy direction is sound - no branch I exercised routes a dm-only body outward.

Compatibility: every .actions read filters by kind == (discord-bridge.py:4059/4191/4255/4592/4616/4846/4861/4896, slack-bridge.py:1256/1263/1455, telegram-bridge.py:1052/1067), none does truthiness or exhaustive matching, so the new dm-only kind cannot crash or misroute an existing consumer.

Non-blocking findings, none previously raised:

  1. src/result_markers.py:132 - the regex is unanchored and case-insensitive, so a body that only discusses the marker matches. parse_markers("The marker is [DM-Only] per docs.\n[channel: 1527...]\npublic") returns body "The marker is per docs." with the redirect silently dropped. Text is mangled and an intended redirect is suppressed with no log. Fails safe (toward DM), so not blocking; anchoring to its own line would remove the false positive.
  2. Lesson 1 - the redirect-on-proactive path is real (discord-bridge.py:4616; Slack _send_reply honors [channel: C...] on proactive text), but synthesize() always emits parts[0] = greeting (src/morning-briefing.py:346) and _REDIRECT_RE anchors at body start, so this producer could never have been redirected. The marker is defense-in-depth here; the agent-authored proactive result that actually leaked stays unprotected because nothing requires the marker. Worth a follow-up issue.
  3. tests/result-markers-golden.test.py claims to pin every marker path but gained no dm-only case.

Worst case: a host running an older packages/ag2-sparrow copy of result_markers.py against the new morning-briefing.py delivers a literal [dm-only] line in the owner's DM - cosmetic, no privacy loss, self-healing on upgrade.

I did not run the bridges live and could not inspect the original leaking result file.

Merge-readiness: ready to merge once the branch is updated (currently BEHIND). The three notes above are non-blocking.

@github-actions

Copy link
Copy Markdown
Contributor

@cla-assistant check

@qingyun-wu
qingyun-wu merged commit 5b51fe9 into sonichi:main Jul 27, 2026
18 checks passed

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approval signal for current head cfc384112a0ad1ca6c2130fb2bb9a93862a7b11b.

Re-reviewed the merge-refreshed private-data DM-only routing head in the isolated /private/tmp/monitor-sutando-20260727-1330/pr-2170 checkout. The marker contract still suppresses [channel:] redirects when [dm-only] is present and keeps marker stripping behavior covered.

Verification:

  • git diff --check origin/main...HEAD - PASS
  • python3 tests/result-markers.test.py - PASS
  • python3 tests/result-markers-golden.test.py - PASS
  • python3 tests/morning-briefing-calendar-unavailable.test.py - PASS

GitHub CI had restarted on this merge-refresh head and was still running at review time; no blocking finding from the PR-owned diff.

Reviewed by Qingyun's Personal Codex.

sonichi added a commit that referenced this pull request Aug 1, 2026
Answers john-the-dev's P1 on 15d4a08.

`src/task-bridge.ts` is the one result consumer that cannot call
parse_markers — wrong language — so it hand-rolls the strip. It kept
`/\[dm-only\]\s*/gi` after the Python side was narrowed, which left the SAME
body preserved on Discord/Slack/Telegram/Sparrow and silently rewritten on
voice/task:

  in  "- #2170 [dm-only]: closes the leak vector"
  out "- #2170 : closes the leak vector"

Inconsistent delivery is worse than uniformly-wrong delivery. Narrowed to
`/^[ \t]*\[dm-only\][ \t]*\r?\n?/gim`, mirroring the Python regex.

Parity executed on both engines, same inputs — dm-only handling is now
identical. One residual difference, stated rather than glossed: a
`[channel:]` marker survives the TS path. That is pre-existing and unchanged
— the old expression did not strip it either, and this consumer does not do
channel redirects.

Also corrected the two comments the narrowing falsified, in both synchronized
copies: the docstring said "The marker is stripped from the delivered text"
and the declaration said "occurrences are stripped from the delivered body".
Both now distinguish DETECTION (anywhere, unchanged, which is what makes the
guard order-independent) from STRIPPING (standalone only).

The structural point, which is why this regressed at all:
`tests/bridge-marker-no-leak.test.py` guards "every bridge routes marker
decisions through parse_markers" — and task-bridge.ts is the one consumer it
STRUCTURALLY cannot cover. The hole was exactly where the bug landed. Added a
TS-parity class that pins the hand-rolled expression to the Python semantics:
anchored + multiline + case-insensitive, and executed against node.

  suite at this head          15 passed
  suite vs origin/main        FAILED (4) — all four TS-parity cases
  result-markers.test.py      34 passed
  golden corpus               13 cases pass
  bridge-marker-no-leak       pass
  discord prose-marker        pass
  package --check             in sync

Note on the test's placement: I first appended these cases to
bridge-marker-no-leak.test.py, whose module ends in `sys.exit(main())` and
never calls unittest.main() — they would have been collected by nothing and
run never. Caught by checking that the run actually reported "Ran N tests"
instead of trusting its PASS line. Moved to the standalone suite.

Stand: Echo Act IV Mini
john-the-dev added a commit that referenced this pull request Aug 1, 2026
…2481)

* fix(markers): stop [dm-only] editing prose that merely mentions it

`[dm-only]` is DETECTED with search()-anywhere, deliberately: that is what
makes the privacy guard undefeatable by marker ORDER, so a body carrying
private data can never be redirected to a shared channel. Detection is
UNCHANGED here, and over-triggering it still fails SAFE.

The same regex also STRIPPED every occurrence from the delivered body, so a
result that merely discussed the marker was silently rewritten:

  in   - #2170 [dm-only]: closes the leak vector, but only suppresses redirects
  out  - #2170 : closes the leak vector, but only suppresses redirects

That is not a routing outcome and does not fail safe — it is silent
corruption of owner-facing text with no indication. Detection (search, :194)
and stripping (sub, :197) were already separate statements, so narrowing the
strip costs the guard nothing.

Now only a STANDALONE marker — alone on its line — is stripped.

One existing assertion changes, and the evidence says it was incidental.
tests/result-markers.test.py:118 asserted `[dm-only]` was absent from the body
of "secret [dm-only] stuff". Measured across the whole tests/ tree: THREE
dm-only bodies use a standalone marker, exactly ONE is inline — that one — and
it sits inside a test whose name and comment are both about ORDER
("dm-only AFTER the channel marker still wins"). Its two routing assertions
are untouched and still pass; only the incidental body assertion flips, with a
comment explaining why the marker now stays.

I filed this as an owner decision one pass ago because I could not break the
tie on taste. This is not taste: it is a corpus count, independently
reproduced, and it resolves which reading the test intended.

  new test at this head              11 passed
  same test vs origin/main            FAILED (3) — the mangling cases
  tests/result-markers.test.py        34 passed
  result-markers-golden.test.py       13 corpus cases passed
  discord-bridge-prose-marker.test.py passed
  bridge-marker-no-leak.test.py       passed

Found by Sutando-Pro applying a discriminator I had derived on the WIRE
routers — "can free text reach the routed line?" — to this module. My first
analysis stopped at routing and concluded no fix was warranted; Pro pointed
out the second effect, which is the one that actually matters.

Stand: Echo Act IV Mini

* chore(sparrow): sync the vendored result_markers copy

CI caught what I missed: `AssertionError: DRIFT — package out of sync with
src/: result_markers.py`.

`packages/ag2-sparrow/ag2_sparrow/` carries vendored copies of five src
modules so the package installs standalone, and a `--check` guard asserts they
match. My change touched `src/result_markers.py` only, so the copy drifted.

Refreshed with the repo's own tool (`packages/ag2-sparrow/tools/sync_from_src.py`).
It reported "synced 5 modules", so I checked what actually moved rather than
trusting the count: `git status` shows exactly ONE modified file, the other
four were already identical.

  --check                          in sync ✓ (exit 0)
  cmp src vs package copy          identical
  tests/result-markers.test.py     34 passed
  dm-only-strip-preserves-prose    11 passed

The guard did its job — a vendored copy silently diverging is precisely the
kind of thing that only shows up later, in the package, where nobody is
looking.

Stand: Echo Act IV Mini

* fix(task-bridge): the voice/task consumer still rewrote prose

Answers john-the-dev's P1 on 15d4a08.

`src/task-bridge.ts` is the one result consumer that cannot call
parse_markers — wrong language — so it hand-rolls the strip. It kept
`/\[dm-only\]\s*/gi` after the Python side was narrowed, which left the SAME
body preserved on Discord/Slack/Telegram/Sparrow and silently rewritten on
voice/task:

  in  "- #2170 [dm-only]: closes the leak vector"
  out "- #2170 : closes the leak vector"

Inconsistent delivery is worse than uniformly-wrong delivery. Narrowed to
`/^[ \t]*\[dm-only\][ \t]*\r?\n?/gim`, mirroring the Python regex.

Parity executed on both engines, same inputs — dm-only handling is now
identical. One residual difference, stated rather than glossed: a
`[channel:]` marker survives the TS path. That is pre-existing and unchanged
— the old expression did not strip it either, and this consumer does not do
channel redirects.

Also corrected the two comments the narrowing falsified, in both synchronized
copies: the docstring said "The marker is stripped from the delivered text"
and the declaration said "occurrences are stripped from the delivered body".
Both now distinguish DETECTION (anywhere, unchanged, which is what makes the
guard order-independent) from STRIPPING (standalone only).

The structural point, which is why this regressed at all:
`tests/bridge-marker-no-leak.test.py` guards "every bridge routes marker
decisions through parse_markers" — and task-bridge.ts is the one consumer it
STRUCTURALLY cannot cover. The hole was exactly where the bug landed. Added a
TS-parity class that pins the hand-rolled expression to the Python semantics:
anchored + multiline + case-insensitive, and executed against node.

  suite at this head          15 passed
  suite vs origin/main        FAILED (4) — all four TS-parity cases
  result-markers.test.py      34 passed
  golden corpus               13 cases pass
  bridge-marker-no-leak       pass
  discord prose-marker        pass
  package --check             in sync

Note on the test's placement: I first appended these cases to
bridge-marker-no-leak.test.py, whose module ends in `sys.exit(main())` and
never calls unittest.main() — they would have been collected by nothing and
run never. Caught by checking that the run actually reported "Ran N tests"
instead of trusting its PASS line. Moved to the standalone suite.

Stand: Echo Act IV Mini

* docs(markers): the dm-only spec described the behavior this PR removed

john-the-dev's requested change at exact head 0f4ebea. CLAUDE.md:363 and its
generated twin AGENTS.md:363 both still said:

    Stripped before delivery and before voice speaks it.

That sentence was true before this PR and is false after it. This PR
deliberately preserves inline mentions on every text bridge AND on the
voice/task path, so the spec now describes the old behavior -- the same
behavior that produced the task-bridge bug this PR fixes. Shipping the code
change while leaving the spec asserting the removed behavior is how the next
reader "restores" the bug on purpose.

Now states both halves, because they are separate and only one changed:

    Detected anywhere in the body -- that is what makes the guard
    undefeatable by marker order, and over-triggering it fails safe.
    Stripped only when the marker stands alone on its line, before delivery
    and before voice speaks it; a marker mentioned inline in prose is
    detected but the text is delivered verbatim.

AGENTS.md is GENERATED (scripts/agents-md-sync.sh), so it was regenerated
rather than hand-edited. The sync gate is the CI command itself:

    $ bash scripts/agents-md-sync.sh --check
    agents-md-sync: AGENTS.md is up to date        (exit 0)

Docs only -- no code touched. The focused suite still passes at this head
(15/15). Repo-wide grep confirms nothing else asserted the old sentence.

Withdrawing part of my own earlier comment: I claimed the three SKIP marker
entries were "silent" about needing to be at body start. The section header
already says "when the result body STARTS with one of these", so they are
covered and I overstated the gap. Not adding words for it here, and not
widening a blocked PR on a point I got wrong.

Stand: Echo Act IV Mini

* fix(markers): the parser comment still promised the stripping this PR removed

john-the-dev's remaining P2 at head 0f88adc. Both synchronized copies still
carried, immediately above the substitution:

    src/result_markers.py:205-206
    packages/ag2-sparrow/ag2_sparrow/result_markers.py:205-206

    # `[channel:]` redirect no matter which appears first. Strip every
    # occurrence from the body ...

with `body = _DMONLY_STRIP_RE.sub("", body)` on the next line, which strips
only a standalone marker. The previous commit corrected the agent-level docs
and left this one, so the false contract sat closer to the code than the true
one. That is the maintenance instruction a future reader would follow to
"restore" anywhere-stripping -- the exact regression this PR exists to fix.

Rewritten to state the two scopes as the different things they are, and WHY
they differ, so the asymmetry does not read as an oversight to be tidied up:

    detect  -- anywhere (not anchored) ... undefeatable by marker ORDER, and
               over-triggering fails SAFE (a reply goes to the DM).
    strip   -- standalone only (`_DMONLY_STRIP_RE`) ... stripping every
               occurrence rewrote the owner's own prose; that is not a routing
               outcome and does not fail safe.

src is canonical and the sparrow copy is bundled from it; both were updated
identically and the drift gate confirms it. Repo-wide grep: 0 remaining
occurrences of "Strip every".

Also cleared the two invalid-escape warnings you flagged as non-blocking --
two docstrings in the focused test are now raw strings. Compiling the file
emits no warnings; suite unchanged at 15/15.

Verified at this head:

    dm-only focused          15/15 OK
    result-markers           34/34 OK
    result-markers-golden    all 13 corpus cases passed
    bridge-marker-no-leak    PASS
    ag2-sparrow-drift        PASS -- bundled utils in sync with src/
    agents-md-sync --check   AGENTS.md is up to date (exit 0)

Comment-only in the parser; no behavior change.

Stand: Echo Act IV Mini

---------

Co-authored-by: Rui Wang <52230987+john-the-dev@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ag2product AG2 product related (packages, PyPI, task-relay)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants