feat(sparrow): built-in 👀 observed-receipt, opt-in with the event plane - #2319
Conversation
… plane New default_observer.ReactObserverHandler — a chain-transparent tee around the event-consumer handler: every message.created authored by someone other than this agent gets a 👀 reaction posted back to the room (the visible 'this agent saw it' receipt), then the event flows to the wrapped handler unchanged. Client-side by design: the receipt must live and die with the agent's own consumer — a server-side default would ack for dead agents. Default ON whenever SPARROW_EVENTS runs; SPARROW_OBSERVE_REACT=0 opts out. Off without AGENT_MXID (self-echo suppression is mandatory — an agent acking its own messages is noise). Bounded FIFO dedup for at-least-once redelivery; all react failures swallowed+logged, never breaking consumption.
Coverage Gate✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 82%. Diff CoverageDiff: origin/main...HEAD, staged and unstaged changesNo lines with coverage information in this diff. |
|
Blocking review for current head
I did not see AppService namespace credentials moving into Sutando-local; the observer uses the existing scoped gateway bearer, and the gateway/broker remains the membership/auth boundary. Local checks: Reviewed by Qingyun's Personal Codex. |
qingyun-wu
left a comment
There was a problem hiding this comment.
Additional blocking finding on current head 4763db75af97345ea351420488f9041837ff743b (not repeating the existing CI-discovery and room-id encoding findings).
packages/ag2-sparrow/ag2_sparrow/default_observer.py:90 performs the reaction urlopen() synchronously inside offer(), before delegating to the wrapped handler. EventConsumer.drain() calls handlers sequentially, so one slow gateway response blocks decision routing/taskify for up to the 10-second default; a backlog multiplies that delay per message. A focused repro with a 250 ms stubbed response showed both offer() and the inner handler delayed by 255 ms. That means this default-on courtesy path can wedge the entire events lane even though failures are caught. Please isolate reaction I/O behind a bounded worker/queue (with a clear overflow policy) or otherwise make it non-blocking, and add a regression proving a slow react endpoint does not delay the inner handler/event drain.
Reviewed by Qingyun's Personal Codex.
…ction Air's #2319 default_observer keys on event type 'message.created' — the 👀 observed-receipt reacts to each new MESSAGE, not to reactions (which would be circular). Align the react-baseline pack entry's event_types so the registration side (this pack) and the consumer (#2319) meet on the same event type. Confirmed against #2319's default_observer._maybe_react. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Reviewed (001) — clean, ship-shape. The chain-transparent tee is the right shape: Seam confirmed in code: this consumer keys on |
The coverage gate discovers only tests/**/*.test.py; a suite under packages/ag2-sparrow/tests/ is invisible to it (and the CI-coverage guard rightly flagged the orphan). Same layout the policy-pack suite uses. Content unchanged; only the sys.path insert now points at the package from the repo root.
|
Blocking review for current head
The CI-discovery blocker is fixed: Reviewed by Qingyun's Personal Codex. |
john-the-dev
left a comment
There was a problem hiding this comment.
Blocking review on current head 8ee01944.
-
packages/ag2-sparrow/ag2_sparrow/default_observer.py:85leaves/unescaped in the room path becausequote()defaults tosafe="/". On this exact head, a synthetic room!a/b:hsproduced.../v1/rooms/%21a/b%3Ahs/react, splitting the room identifier across URL segments. Usequote(str(room), safe="")and pin it with a regression. -
offer()still performs the synchronous reaction before calling the inner handler. Withurlopen()stubbed to take 250 ms, the inner handler was delayed 260 ms (elapsed_before_inner=0.260s). Since event drain is sequential and the network timeout is 10 seconds, this default-on courtesy path can delay taskify/decision routing once per backlog item. Put reaction delivery behind a bounded non-blocking mechanism with an explicit overflow policy, and add a test proving slow reaction I/O does not delay delegation.
The existing observer and CI-discovery suites pass, as do git diff --check and the full added-line path scan; these two runtime defects remain blocking. After fixing them, the PR still needs the real post-restart gateway round trip promised in its body before merge because this changes a default-on network/delivery path.
Reviewed by John’s Codex.
…unded queue Review blockers on 8ee0194: 1. quote() defaulted to safe='/', so a room id containing '/' split the /v1/rooms/{room}/react path across segments. Now quote(room, safe='') like every other gateway room-path call site; regression included. 2. offer() performed the reaction synchronously before delegating, so a slow /react endpoint delayed every event in the sequential drain. Delivery now goes through a bounded queue (cap 256) drained by a lazily-started daemon worker; offer() only builds+enqueues. Overflow policy is explicit: drop the receipt and log — never block, never retry (courtesy signal; the message stays marked seen). flush() exposes drain-completion for tests and graceful stops. Tests: slash-room escaping, 250ms-slow endpoint leaves offer() <100ms, wedged endpoint + full queue drops-and-logs without blocking. Full suite + src/remote-gateway-bridge.test.py green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sjr9e7xKnDsU3L6DoQd8tt
|
Both blockers addressed on the new head:
Evidence at this head: Remaining before merge per the review: the real post-restart gateway round trip. That needs the events channel restarted against the live gateway on the deployment host — will attach once run in a window that doesn't disturb live testing. |
|
Approval signal for current head No remaining code blockers from my review pass. The previous room-path escaping issue is fixed with Validation in
I would still honor the PR body's live-path note and attach the real post-restart gateway round-trip evidence before merge, but I do not see a remaining code-review blocker on this head. Reviewed by Qingyun's Personal Codex. |
|
Both blockers addressed at
Evidence at this head: @john-the-dev re-review when you get a chance — both items have dedicated regressions at the new head. |
|
@john-the-dev both findings are addressed at the current head
CI green (tsc/tests/coverage). Could you re-review the current head to clear the block? Thanks. |
|
Live post-restart evidence (real deployment, PR head's observer + wiring patched in, events enabled) — plus two findings from the run. What worked:
Finding 1 — "default-on" doesn't hold on AGENT_ID deployments. This deployment's durable env names the agent id Finding 2 — backlog drain would receipt-spam a fresh install. First launch with a FRESH event inbox consumed the full historical cursor (0 → 570; the consumer promoted ~87 backlog batches in seconds). The observer happened to be unarmed on that launch — had it been armed, it would have posted a 👀 to every historical Happy to push either fix to this branch if you want them in-PR rather than follow-up. |
…dings Two defects surfaced by the live post-restart run (PR thread): 1. The wiring read only AGENT_MXID; a real deployment's durable env names the id AGENT_ID, so 'default-on' was silently off there. AGENT_ID is now honored as a fallback, and the off-log names both vars. 2. A fresh install's first event drain replays full room history (observed live: new inbox consumed cursor 0→570 in seconds) — an armed observer would have 👀-reacted to every historical message across rooms. New catch-up guard: events older than SPARROW_OBSERVE_MAX_AGE_S (default 300s) are marked seen but not reacted; ts-less events count as live; 0/negative disables. Regressions added for both (AGENT_ID-alone wiring; old-event seen-not- reacted incl. redelivery, fresh + ts-less still react, guard-off path). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DjCFofrj7FTrFnhjMidJ2Y
|
Both findings from the live run are now fixed on this branch at
Note: the TaskifyHandler wiring one line up has the same AGENT_MXID-only read — left untouched here (pre-existing, separate concern); happy to do the one-word follow-up once this lands. |
qingyun-wu
left a comment
There was a problem hiding this comment.
Approval signal for current head 8b71f03e792912e2d0ad6b7d378737f3f9870846.
The prior blocking findings are fixed: room IDs are fully path-escaped, reaction I/O is isolated behind a single bounded worker/queue with an explicit drop policy, and slow/failing reactions no longer delay the inner handler or event drain. The two live-run findings are also addressed: AGENT_ID now enables the observer when AGENT_MXID is absent, and the 300-second catch-up guard marks historical replay seen without receipt-spamming it. I verified the gateway’s make_event() contract stores Matrix origin_server_ts as integer epoch milliseconds, matching the guard’s calculation.
Worst-case disruption is bounded: this is default-on only inside the already opt-in SPARROW_EVENTS plane with an agent ID present; old replay is skipped; a wedged endpoint can consume only one worker and a 256-entry queue before courtesy receipts are dropped; taskify/decision routing remains synchronous and unchanged; and SPARROW_OBSERVE_REACT=0 disables the feature.
Exact-head verification passed: Python compilation; observer, gateway bridge, event inbox/channel, event consumer, event wiring, and human-action suites; CI-discovery guard; git diff --check; repository hardcoded-path scan; all GitHub workflows; and CLA. The live post-restart run also demonstrated a fresh auto-receipt, swallowed duplicate-reaction failure, and a complete inbound-task/outbound-reply round trip.
Ready to merge from my review lane. John’s formal REQUEST_CHANGES was submitted on the older 8ee01944 head and still needs his current-head re-review/clearance. Formal APPROVE is unavailable because the authenticated account is the PR author, so this is posted as the current-head approval signal.
Reviewed by Qingyun's Personal Codex.
|
Approval signal for current head This head is a merge-from-main on top of the previously approved Focused verification in the isolated
GitHub CI is green. I do not see a remaining code-review blocker on this head. Reviewed by Qingyun's Personal Codex. |
Superseded by a current-head review: both code defects are fixed; only the required live-path evidence remains.
john-the-dev
left a comment
There was a problem hiding this comment.
Re-reviewed current head 3bc7784. The prior code blockers are resolved: room IDs now use quote(..., safe=""), and reaction I/O is behind a bounded worker queue with overflow behavior and regression coverage. All required checks are green. The remaining merge gate is the live-path evidence already promised in the body: a real post-restart gateway event -> non-blocking receipt reaction -> delegated/observed outcome round trip. Please attach that run; this review replaces my stale code-focused request.
|
Live-path evidence complete — the promised round trip ran against the production gateway at 15:51–15:55Z with the PR-head bridge: Setup: PR-head bridge started with Round trip:
Also observed live: on connect, the SSE backlog replayed historical events and the catch-up age guard correctly produced zero retro-reactions — only the fresh message got the 👀. That closes the remaining merge gate from the re-review. (Own-actor messages correctly get no reaction — my own ping message that prompted the reply carries no self-👀 from this instance.) |
qingyun-wu
left a comment
There was a problem hiding this comment.
Approval signal for current head 3bc7784de9db10ef7230d6b91b014fc44c1806d3.
The promised live gate is now satisfied: after a real restart, a fresh peer-authored gateway event produced a room-visible 👀 from this agent while the same message continued through the normal delegated task path to completion. The backlog replay produced no retro-reactions, so the catch-up guard also exercised the worst-case default-on failure mode against real history.
The full current diff and activated event-consumer path remain sound. The wrapper preserves the inner handler's settlement contract, reaction I/O is isolated behind a bounded non-blocking queue, room ids are fully path-escaped, self messages and duplicate deliveries are suppressed, old events are marked seen without reacting, and every reaction failure is swallowed without affecting task delivery. Existing users are protected because the feature only activates with the already-opted-in event plane plus an agent id, and SPARROW_OBSERVE_REACT=0 remains an explicit opt-out.
Focused verification on this head:
git diff --check fdfd7b4...HEAD- Python compilation of the observer, bridge, and focused test
python3 tests/default-observer.test.py— PASSpython3 packages/ag2-sparrow/tests/test_event_wiring.py— PASSpython3 packages/ag2-sparrow/tests/test_event_consumer.py— PASS
GitHub CI and CLA are green. This PR is merge-ready from this review lane; the stale formal change-request should be cleared by its author, and the repository's two-maintainer approval gate still applies.
Non-blocking integration note: if #2323 lands first, resolve the nearby wiring against its shared _AUTH_HEADERS object so observed receipts inherit later token rotations instead of retaining a copied bearer.
Reviewed by Qingyun's Personal Codex.
|
@john-the-dev — the live gate you asked for is satisfied; requesting re-review. Your remaining ask: a real post-restart event → non-blocking receipt reaction → delegated/observed outcome round trip. |
bassilkhilo-ag2
left a comment
There was a problem hiding this comment.
Commenting (already blocked; no stacked verdict). The engineering here is strong and I want to be specific about that before the concern, because the concern is about default scope, not about the code.
What's genuinely well done:
- Client-side rather than a server default, with the right justification: the receipt means "this agent's consumer observed the event", so a server-side default would emit receipts on behalf of dead agents — a false liveness signal. That's a sharp distinction most implementations miss.
- Chain-transparent wrapper, not a chain member.
HandlerChainroutes to one claiming handler; teeing the react and returning the inner result unchanged keeps routing/settlement (taskify batching, decision routing) untouched. Correct call. - Async via a bounded queue + single daemon worker, so a wedged
/reactcan't stall the sequential drain — and overflow drops rather than blocks or retries, with the tradeoff stated outright ("courtesy signal, not a delivery guarantee"). That's the right default for a cosmetic signal. - The catchup guard skipping backlog is important and easy to forget — without it, a reconnect would 👀 the entire history.
The concern: default-on has no room scoping, and "every member room" is bigger than it sounds
The react target is simply whatever room the event arrived from:
room = event.get("room_id")Skips are limited to self-authored, non-message, missing-message_id, dup, and backlog. There is no room allow-list and no room-class distinction — so with SPARROW_EVENTS on, this posts 👀 on every human message in every subscribed room.
Measured on this host: the agent is joined to 17 rooms. Only one is the owner's private DM. The rest are shared, human-populated team rooms — including AG2 General Room, GTM - Strategy, GTM - Engineering, PR Review Room, ag2space-backend, AG2 Investor Relations, and Deal Room - VC.
So the default behavior is: the owner's agent reacts 👀 to every message every human sends in an investor-relations room and a VC deal room. The PR frames "zero per-user setup" as the benefit — the flip side is zero consent from the other people in those rooms, who see a bot reacting to each of their messages and cannot turn it off (only the agent's owner can, via SPARROW_OBSERVE_REACT=0, which is all-or-nothing).
I don't think this needs to block the mechanism, which is good. But the default deserves a narrower scope than "every member room". Options, cheapest first:
- Default to DM/owner-scoped rooms only, opt in per-room for shared ones. The receipt is most valuable exactly where the owner is waiting on the agent.
- Keep the broad default but suppress in rooms with more than N human members — the signal's value drops and its noise cost rises with room population.
- React only to messages that address the agent (mention or reply-to), which is where "did you see this?" is actually being asked.
Related: see my note on #2320, which fans out to one policy per member room — the two PRs meet on this line, and the scope decision belongs to whichever lands first.
…t-wiring is a follow-up) (#2320) * feat(observe): default policy pack — factory-default subscriptions, owner-visible + disable-able Auto-registers a set of pre-blessed standing-approval policies on agent first-connect (no manual config). First entry: 👀 react baseline (observe m.reaction across all member rooms), owner-visible and individually disable-able. Reuses observe_policy.validate_draft + evaluate_standing_approval rather than bypassing the boundary — a pack entry that fails the locked scope is refused, never silently activated (fail-closed; tested). Fans cross-room entries out to concrete per-room records so observe_policy's room_id invariant holds and air's sparrow consumer (#2319) stays zero-change. Disable cancels live per-room subs + bumps a per-entry generation; deterministic per-room ids keep connect-time re-seed idempotent. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(observe): cover default-pack CLI + branch paths (diff coverage 71%→99%) Add tests for list_pack, unknown-entry fail-closed branches, on_room_join default-scope + disabled-skip, wrong-scope entry skip, the owner-ops CLI (main() via argv with an explicit --store, incl. error exits), and _default_store_dir resolution. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(observe): react baseline subscribes to message.created, not m.reaction Air's #2319 default_observer keys on event type 'message.created' — the 👀 observed-receipt reacts to each new MESSAGE, not to reactions (which would be circular). Align the react-baseline pack entry's event_types so the registration side (this pack) and the consumer (#2319) meet on the same event type. Confirmed against #2319's default_observer._maybe_react. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(observe): design doc says message.created (was stale m.reaction) Follow-up to the code fix (e22c175) — the design doc's react-baseline line still described the old m.reaction event type. Align it. (air review nit) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(observe): seed_room is idempotent on ANY existing generation record (never resurrect owner-cancelled) John #2320: seed_room only skipped a record whose status was `active`, so a current-generation record the owner had directly cancelled was re-seeded and transitioned back to `active` on the next connect-time reseed — silently undoing the owner's cancellation. Fix: skip if a current-generation record EXISTS in any state. `cancelled` is terminal (the owner's decision); a genuine re-enable bumps the entry's generation, yielding a fresh pid with no existing record, so re-enabling still seeds — only same-generation reseed is suppressed. Regression: seed → owner transitions one record to `cancelled` (same generation) → reseed → assert it stays `cancelled` and is reported `skipped`. Full suite 50/50 (the disable→re-enable path is unaffected). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(observe): scope the default-pack prose to what's wired — seed machinery, not connect-time registration Review blocker (#2320): the title/What/docstring/design-goal claimed the pack "auto-registers on agent first-connect / is the registration side," but the only caller of seed_defaults() is the CLI __main__ block — no production connect-time caller exists (a full-tree search finds none). The PR's own "Follow-up (not in this PR)" section already lists the connect/join hooks as follow-up, which contradicts the assertive top-line prose. Align the claims with the delivered scope: this PR ships the pack DEFINITION + seed_defaults()/on_room_join() (CLI-invokable, tested), designed to run at connect/join via a one-line hook that is an explicit follow-up in the events/room-ops layer — not wired here. Doc/docstring only; no behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(observe): resume a crash-interrupted draft on reseed, don't skip it forever Review blocker (#2320, my inline finding at default_policy_pack.py:151): seed_room does store.save() then a SEPARATE store.transition(pid, "active") — two atomic writes. A crash between them leaves a non-terminal DRAFT for this (entry, generation, room). The existing-record guard skipped ANY existing record, so reconnect kept finding the deterministic draft, returned "skipped", and left that room UNSUBSCRIBED FOREVER. Distinguish states in the guard: an existing `draft` is a crash-interrupted seed — re-run the standing boundary (the owner's scope may have changed since the crash; refuse if it no longer passes) and transition it to active, so the seed self-heals on the next connect. `active` (already seeded) and `cancelled` (the owner's direct cancellation — never resurrect) still skip. Test: test_reseed_resumes_crash_interrupted_draft saves a validated draft (simulating the crash), reseeds, asserts status "resumed" + stored record active, plus idempotence on the next reseed. Verified it fails without the draft-resume branch (disable-the-repair). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(observe): bound the pack's aggregate spend, don't just cap each draft evaluate_standing_approval() checks ONE draft's cost cap, which says nothing about how many drafts there may be. Fanning an entry across every member room multiplies it, and every later join multiplies it again. Reproduced on 144ea82: advertised default = 2 3 rooms -> caps [2,2,2] aggregate = 6 ...then on_room_join('!d') aggregate = 8 Each draft passes the boundary individually while the total the owner actually authorized grows with no policy edit and no renewed approval. A per-draft ceiling structurally cannot express "how much in total" — it sees one draft and cannot know it is the twelfth. The pack now carries its own aggregate budget, enforced at BOTH activation sites (fresh seed and the crash-resume path — a resumed draft is not yet active, so leaving it unchecked would make crashing a way to exceed the budget). Beyond the budget a room is not silently dropped and not silently activated: it is refused with a reason naming the budget, so it surfaces as an explicit card the owner can approve. The boundary degrades to CONSENT, which is the only direction that is safe to get wrong — a wrong "needs a click" costs a click, a wrong "auto-activated" spends the owner's budget without asking. The budget counts only pack-provenance records. Counting owner-approved policies too would let an explicit approval shrink the automatic allowance, so approving something would make the next automatic grant harder — backwards. Regressions pin the contract, per the review ask: 15-room fan-out stays bounded, a later join cannot widen it, an under-budget join STILL seeds (calibration — the other assertions are all satisfied by a blanket refusal that would break the feature outright), repeated reseeds do not double-count, and a non-pack policy contributes zero. The suite computes the aggregate from the store on disk rather than calling the module's own accounting, so a miscount cannot confirm itself. Branch refreshed against main (79 commits) per the review. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(observe): back the "explicit approval" promise with an actual record The refusal copy said explicit approval was required for an over-budget room, but seed_room() returned before store.save(), so the deterministic policy_id did not exist. transition(pid,"active") had nothing to act on and the result carried none of the fields a confirmation card needs. Verified on 08cedd9 with an 11-room seed: 10 seeded, 1 refused, store.get(refused) is None, result keys were only {entry, policy_id, reason, room_id, status}. So rooms past the budget were neither auto-subscribed NOR owner-actionable — which is precisely the silent drop the budget exists to prevent. The guard was correct about what NOT to do and wrong about what happens instead. The over-budget policy is now persisted as a DRAFT. A draft is the right state: inspectable, consumes no budget (committed_evals_per_day counts ACTIVE only), and the resume path re-runs the same check so it cannot self-activate while over budget — but it DOES activate on the next seed once a room is cancelled and the allowance frees. Self-healing instead of requiring an owner re-seed dance. Guards: the refused room now leaves an approvable record and transition() works; a persisted draft does NOT resume while saturated (control — seed_room treats an existing draft as crash-interrupted, so persisting one could have opened a back door that activates on reconnect); and it DOES resume once budget frees (calibration — the control alone is satisfied by a draft that can never activate, which would make "awaiting approval" a dead end). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(observe): disabling the pack must revoke its pending drafts too Follow-on blocker created by the previous fix. set_enabled(False) cancelled only ACTIVE records; the over-budget draft that fix started persisting is not active, so it survived the disable — and `draft -> active` is a legal transition, so an approval card minted before the disable still activated a room afterwards. Reproduced on adbd1b5: before_disable draft / disable {'cancelled_rooms': 10} / after_disable draft activate_stale True / entry_enabled False / final_status active The owner's disable was advisory, which is the one thing a disable must not be. Cancelling closes it completely rather than partially: `cancelled` is TERMINAL in the store (transition permits draft->{active,cancelled} and active->{cancelled,expired}, nothing out of cancelled), so a late click on a stale card now returns False instead of resurrecting the room. That is why no separate check is added to the activation path — the guard belongs in the state machine, not in a caller that could forget to ask. Guards: disable revokes the draft AND a stale approval is refused afterwards (the status is not the point; the refused activation is). Plus a calibration that disable-then-re-enable still seeds a fresh generation, since the first guard is equally satisfied by a disable that destroys the entry permanently. The general shape, worth stating because it is what I missed: a fix that introduces a NEW RECORD TYPE has to be walked through every lifecycle that enumerates records, not only the one it was written for. This draft was reachable by seeding, budget accounting, resume, and disable; I had considered the first three. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(observe): make disable authoritative against a seed already in flight TOCTOU in the sweep added by the previous fix. set_enabled(False) read the draft list, cancelled, and only THEN committed disabled=True — so a seed already in flight could persist a record into that gap, absent from the sweep and not yet gated by the flag. Reproduced on 4d50448, two variants of one root cause: over budget -> persisted as `draft`, survived the disable, and a late approval click activated it: activate_late True, final active under budget -> the sweep had already freed allowance, so the racing seed went straight to `active` and needed no click at all — a live subscription on an entry reporting disabled The second is worse and ordering alone cannot catch it, so the fix is both: 1. Commit disabled/generation BEFORE sweeping. The window inverts: anything persisted before the commit is caught by the sweep that now runs after it. 2. Revalidate at every persist/activate point in seed_room (_entry_still_live re-READS pack state rather than trusting what was loaded at entry). Anything still in flight after the commit sees the disable and refuses. Together they cover both halves without a lock the store does not have. The generation is checked too, so a stale in-flight seed cannot land on a re-enabled entry's fresh generation. Regressions pin both variants plus the ordering itself — the latter asserted directly (a reader observing mid-sweep sees the entry already disabled) rather than inferred from the race passing. Note for the reviewer: the first version of this guard broke three existing resume tests because I inserted it at the wrong indentation, making the resume path's activation dead code after a return. The existing suite caught it. Kept as a reminder that a guard added to a nested branch is a structural edit, not an insertion. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(observe): an owner approval must not consume the pack's automatic allowance Found by enumerating the pack's lifecycles rather than waiting for the next review — the previous four rounds each surfaced one lifecycle at a time, so this pass walked them deliberately: seed, resume, budget, disable, re-enable, approve. The approve cell was wrong. committed_evals_per_day()'s own docstring states the rule: "approving something should never make the next automatic grant harder." But an over-budget draft was persisted with plain pack provenance, so approving it counted against the very budget that had refused it. Measured on df4b7b3 with 13 rooms: seeded 10, aggregate 20/20, 3 rooms queued as approvable drafts owner approves all 3 -> aggregate 26/20 brand-new room joins -> refused (26 + 2 > 20) cancel a room and retry -> still refused The pack could never auto-seed again. Note the shape: the code implemented NEITHER policy cleanly — approval could exceed the cap (so the cap was not a hard total) while also consuming the allowance (so approvals were not outside it). Over-budget drafts now carry `pack.over_budget = True`, and the accounting skips those records permanently, including after approval. The budget bounds what the pack grants ITSELF; an explicit owner decision is deliberately outside it, which is what the module already claimed to do. If the intended contract is instead a hard total across both, that is a different change — the approval path would need its own budget check — and it is an owner policy call, not a refactor. Flagged rather than assumed. Calibrated: the cap still bounds automatic grants (13 rooms -> 10 auto-seeds, aggregate 20), since the new guard is equally satisfied by deleting the budget. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(observe): the owner view must show rooms awaiting the owner's approval Continued lifecycle enumeration — the `list_pack` cell. The over-budget refusal tells the owner the room "surfaces as an explicit card the owner can approve", but `list_pack` reported only `active_rooms`, so the rooms actually awaiting her decision were invisible in the one view built for her. This is the same defect already fixed once on this PR, one layer up. At the RECORD layer the refusal promised an approval path with nothing approvable behind it; here the record exists and the VIEW omits it. A decision she cannot see is not a decision she has. list_pack now reports `awaiting_approval` alongside `active_rooms`. Guards: every queued room appears, an approved room MOVES between the two lists rather than appearing in both, and — calibration — the field can be empty, since "always lists 3" would satisfy the first assertion just as well. The empty case is a disabled entry, which also re-checks that disable revokes pending drafts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(observe): compare-and-commit so a disable cannot be lost by an in-flight seed `_entry_still_live()` was a READ followed by SEPARATE save/activate writes. A disable landing in that window was missed by both sides: its sweep found no record to cancel (the seed had not saved yet) and the seed had already passed its only check, so an entry the owner had just revoked came back ACTIVE. Reachable across processes, which is what makes it a defect rather than a synthetic interleaving: this module ships a CLI (`disable`) that runs against the same store dir while the core handles a room join, so observe_policy's "single-writer (the core), so no lock protocol needed" does not hold here. Fix: publish the record BEFORE the deciding check, then verify. * if the disable's sweep runs after the save, it SEES the record and cancels it -- `cancelled` is terminal, so the activation fails safely; * if the sweep already passed, the re-read observes `disabled` and the seed cancels its own record. Either interleaving ends non-active. transition()'s return value is now honoured; ignoring it is what let a cancelled record report as seeded. The cheap pre-write check is kept: it is what stops a seed racing the sweep from persisting anything at all (the existing racing-seed tests assert exactly that). Evidence, same harness at parent and at HEAD: parent: {"seed_status": "seeded", "stored_status": "active", "entry_enabled": false} HEAD: {"seed_status": "refused", "stored_status": "cancelled", "entry_enabled": false} Regression added with its injection asserted as a control, so it cannot pass vacuously. It FAILS at the parent commit ("got 'active'") and passes here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(observe): close the same seed/disable race on the over-budget draft path The previous commit fixed one instance of the pattern, not the class. The over-budget branch does the same thing the activate path did: `_entry_still_live()` is a READ, `store.save()` is a WRITE, and nothing between them re-verifies. A disable landing there is missed by the sweep (no record yet) and strands a draft on a disabled entry. A stranded DRAFT is not benign, which is the easy mistake: it observes nothing by itself, but `draft -> active` is a legal transition, so an approval card minted before the disable can still activate the room afterwards. That is reproduced on adbd1b5 and is exactly why set_enabled() sweeps pending drafts as well as active records -- so leaving this path unfixed would have re-opened the hole that sweep exists to close. Same publish-then-verify as the activate path: save first, re-read, cancel the record if the entry went away. Regression: test_over_budget_draft_racing_a_disable_is_not_left_live. It FAILS at the parent ("got 'draft'") and passes here. Note on its control: asserting the refusal RESULT would have been worthless, since the reason string flips from the budget message to the disabled message once the fix lands -- any "status == refused" check then passes without proving the over-budget branch was reached at all. The control asserts the PRECONDITION instead (committed == PACK_AGGREGATE_EVALS_PER_DAY, 20/20), which is independent of which branch the code takes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(observe): the resume path re-save resurrects a cancelled record Review flagged this branch for ignoring transition()'s return value. It is that, but the measurement came back worse than the diagnosis: the record ends ACTIVE, not merely mis-reported. set_enabled() argues that cancelling is sufficient on its own because `cancelled` is terminal -- "the guard lives in the state machine, not in a caller that could forget to ask". That holds for transition(). It does NOT hold for save(), which is a blind whole-record overwrite (json.dump + os.replace) consulting no state machine. So the real sequence on this path is: 1. the disable sweep cancels the pre-existing draft; 2. this re-save OVERWRITES it back to `draft`; 3. the activation then legally succeeds. A disabled entry goes ACTIVE, and the terminality the sweep depends on is silently undone by a writer that never asked. Fix mirrors the other two paths: re-read after the save, cancel if the entry went away, and honour transition()'s return. The regression's control is deliberately NOT "the sweep cancelled it" -- that was true for a moment and then overwritten. It asserts the end-state invariant: a disabled entry is never left with a live record, however many writers touched it. Reverting only this hunk gives 'active' + 'resumed' and exit 1; restored, exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(observe): serialize seed/disable/transition over a per-store lock Closes the two P1 multi-writer races by making the authority check and the write one step instead of two correct steps. 1. transition() was a non-atomic read/modify/write: a disable cancelling a record between its get() and its save() was silently overwritten. It now holds the store lock across both. 2. Budget reservation was check-then-write: two seeds each passed _budget_allows() before either committed, then both activated (measured 18/20 -> committed 22). The reservation now spans budget-check -> save -> activate in one critical section. 3. set_enabled's commit + sweep runs under the same lock, closing the window where a seed writes between the two halves. The lock is process-wide RE-ENTRANT, keyed on the store DIRECTORY -- not on the SubscriptionStore instance and not on the fd. flock attaches to the open file description, so a second open()+LOCK_EX from the same process blocks forever (verified: fd1 holds, fd2 blocks, LOCK_NB gives EWOULDBLOCK). That matters because set_enabled() constructs its own store while a seed path holds a different instance, so per-instance re-entrancy would deadlock on exactly the nesting the racing-seed regressions exercise. LOCK_NB + bounded retry raises StoreLockUnavailable rather than hanging -- a hung seed on a room-join path takes the core with it. Also corrects SubscriptionStore's docstring. It said "Single-writer (the core), so no lock protocol needed"; both P1s trace back to code trusting that sentence, while this module ships a CLI that writes the same store as the running core. Regression: test_store_lock_serializes_a_SEPARATE_PROCESS, across a real fork, with three controls -- (A) lock free -> another process ACQUIRES, proving the probe can say yes; (B) held -> REFUSED; (C) inside a real transition() -> REFUSED, proving the shipped path takes it rather than a hand-rolled `with` in the test. Removing the lock from transition() flips C to ACQUIRED and the suite to FAILED(1). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * style(tests): one import per line — ruff E401 on the new lock regression CI caught what my local run could not: ruff is not installed on this host, so the suites passed while the lint gate failed. The offending line was inside a test function (`import subprocess, textwrap, json as _json`); the remaining comma-import in that diff lives inside the child-process source STRING and is not parsed by ruff. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(observe): state the store lock's thread-awareness boundary Review caveat, non-blocking and confirmed dormant. The re-entrant fast path keys on the store directory alone, so two threads in one process would share the lock without being serialized against each other. Verified dormant rather than asserted: skills/observe contains no threading / asyncio / concurrent.futures reference, and set_enabled() has exactly one production caller (the CLI dispatcher in default_policy_pack.main), which is a separate process by construction. Documents the invariant that makes it safe today plus the exact change if threads are ever introduced -- key re-entrancy on (realpath, get_ident()) and guard with a per-directory threading.Lock, because the flock alone cannot help (two threads in one process contend on separate fds and take the LOCK_NB timeout instead of serializing). Deliberately not built: no threads exist, and a stated invariant a future reader can re-check beats untested concurrency machinery. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(observe): the RESUME branch's budget check was outside the store lock Wrapping the first-seed reservation fixed a call site, not the class. seed_room() calls _budget_allows() TWICE -- the `status == "draft"` resume path has its own copy, and it was still unlocked, so reservation and activation were two steps there. Measured on 95149c7, starting at 18/20: two crash-interrupted drafts both passed the check and both activated -- committed 22, both writers "resumed". Fix: the resume branch's budget-check -> save -> activate now runs inside op.store_lock(store_dir), same as the first-seed path. Regression asserted CROSS-PROCESS, for the same reason as the fork test in observe-policy: an in-process synchronous injection is a nested same-thread call that the re-entrant lock lets through by design, so the property to assert is that another PROCESS cannot enter while this branch sits between its budget check and its activation. Two controls -- lock free -> ACQUIRED (proves the probe can say yes), inside the resume branch -> REFUSED. Reverting only this hunk flips it to ACQUIRED and the suite to FAILED(1). Note the in-process repro still reproduces after this fix, and that is expected rather than a miss -- same distinction as the previous commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(observe): re-read the record inside the lock — a resume could resurrect a cancel Fifth instance of this class, and at the site my own audit cleared last round. `existing = store.get(pid)` is read BEFORE the lock, only to choose the branch. Now that transition() correctly holds the lock, a direct owner cancellation can complete FIRST; the resume branch then proceeds on a stale `draft` view, the blind save rewrites `cancelled` -> `draft`, and the activation legally succeeds. Every existing guard misses it for one reason: `_entry_still_live()` checks the pack ENTRY (enabled + generation), while this is a per-RECORD cancellation. The entry stays enabled, so the check passes and nothing inside the lock was re-reading the record's own status. Measured at 3572d09: transition_cancelled True, result "resumed", stored_status ACTIVE. Fix: compare-and-commit inside the critical section -- re-read the record under the lock and refuse unless it is still `draft`. Regression: test_owner_cancel_racing_a_resume_cannot_resurrect_the_record, with the cancel injected between the pre-lock read and the lock acquisition. Reverting only the guard gives 'active' / 'resumed' and FAILED(3). Why my audit missed it, since the same class has now recurred five times: it enumerated WRITES and asked whether each was locked -- and every write was. It never asked whether the READ that selects the branch could go stale. Enumerating the wrong noun looks exactly like completeness. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(observe): serialize same-policy idempotency with the write Sixth instance, and the SECOND of two blocking reviews at the same head — I fixed only the resume-branch one last round and shipped while this was still open. `existing = store.get(pid)` is read before the lock only to choose a branch. Two writers racing the same room both see None and both reach the first-seed section. The first activates at the final budget slot; the second, now over budget, blind-saves the SAME deterministic policy_id as an over_budget draft. A working subscription is silently downgraded while the first writer has already returned "seeded", and the freed aggregate can fund another grant. _entry_still_live() cannot catch it: it validates pack authority and generation, never whether another writer created this policy_id. Measured at 3572d09: results ['seeded','refused'], record_status 'draft', over_budget True, aggregate back to 18. After: ['seeded','skipped'], record_status 'active', over_budget False, aggregate 20. Fix: re-read the record at the top of the critical section and return `skipped` if one now exists -- idempotency is part of the critical section, not a pre-check. Regression: test_concurrent_seed_of_the_same_room_is_idempotent_not_a_downgrade, with controls that the concurrent writer ran and that writer A did seed. Reverting only this guard gives 'draft' + over_budget and FAILED(2). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * style(observe): trim comments/docstrings to repo policy — constraint-only, ≤2 lines Per the comment policy in AGENTS.md: narration, history, and cross-references moved out of code; design rationale lives in default-pack-design.md and the PR body. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GXgoNuguf17rwK9knwNLk8 --------- Co-authored-by: Qingyun Wu <qingyun@ag2.ai> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Rui Wang <52230987+john-the-dev@users.noreply.github.com>
john-the-dev
left a comment
There was a problem hiding this comment.
Blocker resolved — approving. And I owe you an apology for the delay: you fixed this on 2026-07-30, about two and a half hours after my review, and I did not come back for twelve days. That's on me, not on the PR.
My blocker was that default_observer scoped reactions by the incoming room_id alone — no owner/DM restriction, no allowlist, no membership bound, no addressed/mention test — so default-on meant enabling the event plane would post visible reactions to every human message in every subscribed shared room, where the other participants never consented and have no opt-out.
The fix takes the conservative option, which is the right one while the scoping policy is undecided: make it opt-in rather than pretend to solve scoping.
Verified at afdd3de4:
remote_gateway_bridge.py:1918
if (str(os.environ.get("SPARROW_OBSERVE_REACT", "")).strip().lower() ...
Default is the empty string, so it is falsy — off even when SPARROW_EVENTS is on, which was the specific combination I was worried about. Suite passes, including wiring: no AGENT_MXID/AGENT_ID → observer stays off and AGENT_ID is still honored as the fallback name when opted in, so the wiring is pinned rather than merely documented.
I also want to credit the docstring, because it does something I rarely see: it records why the default is off, restating the scoping gaps concretely, so the next person who thinks "this should obviously default on" reads the reason at the point of decision rather than having to find this thread.
Nothing further from me. It's BEHIND; approvals survive gh pr update-branch on this repo if you want to take it forward.
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact head afdd3de4719662bddb82bc8260f83e741194be25 after the stale formal blocker was cleared.
The shared-room consent fix is functionally sound: observed receipts are default-off, the opt-in positive control bites, and the focused observer suite passes. GitHub cannot formally request changes on this account-owned PR, so this COMMENT is blocking.
[P2] The cumulative added prose violates the explicit two-line code-comment/docstring contract. Representative production blocks are packages/ag2-sparrow/ag2_sparrow/default_observer.py:1-34, the catch-up comment at :59-67, and packages/ag2-sparrow/ag2_sparrow/remote_gateway_bridge.py:1895-1917. The test module docstring at tests/default-observer.test.py:1-4 and test narratives at :273-289 and :315-322 also exceed the cap. Keep only the non-obvious safety constraints in code and move review history, measurements, and walkthroughs to the PR body.
git diff --check and the REVIEW.md hardcoded-path gate pass. Runtime behavior is code-ready, but the repository-contract blocker remains; not merge-ready.
Reviewed by Qingyun's Personal Codex.
The observer's added prose carried review history, incident narrative, and test walkthroughs — the context CLAUDE.md places in the PR body, not in code. Every cited block now keeps only the non-obvious constraint in at most two physical lines. No behavior change: tests/default-observer.test.py and src/remote-gateway-bridge.test.py both still pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
[P2] addressed in Every block you named, plus the four the same rule catches elsewhere in the added lines, is now at most two physical lines and keeps only the constraint the code cannot state itself. The review history, the rationale for opt-in over narrowed-scope, and the AGENT_ID live-deployment finding are removed from code — that context belongs in this PR body and in your review thread, where it stays checkable. Detector ( At the parent ( At this head ( Also removed the one remaining No behavior change — comments and docstrings only. Tests at
|
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact head b16bcd81. The prose blocker is cleared and both focused suites pass locally, including the full remote-gateway bridge suite. GitHub cannot formally request changes on this account-owned PR, so this COMMENT is blocking.
[P1] Current main now enforces the ratified events-plane boundary, and this PR fails it. packages/ag2-sparrow/ag2_sparrow/default_observer.py:100 adds a new /v1/rooms/{room}/react caller in a new Sparrow file, while docs/architecture-boundaries.md freezes Sparrow's room-verb endpoint surface to human_action.py and remote_gateway_bridge.py and says the allowlist may only shrink. The exact hosted clean-install failure is events-plane-boundary.test.py: expected those two files, found default_observer.py as a third (13/14 passed).
Keep the observer policy in the new module, but inject the provider-specific reaction operation from the existing adapter edge (or otherwise comply with the frozen boundary); do not simply grow the allowlist. Add/run the current-main boundary test on the rebased head.
Worst case is architectural drift that recreates a second room-operation client inside the resident event plane. Functional behavior is sound, but the current-main interaction is not; not merge-ready.
…rver holds no room verb
The events-plane boundary freezes Sparrow's room-verb endpoint surface to
human_action.py + remote_gateway_bridge.py; default_observer.py built the
/v1/rooms/{room}/react URL itself, so it read as a third endpoint caller.
ReactObserverHandler now takes an injected react(room_id, message_id, key)
and queues (msg_id, room); the URL and POST move to _react_sender() in
remote_gateway_bridge.py, which routes through the file's own _req() and so
picks up the rotated token, Authorization and explicit UA headers.
All receipt policy stays in the observer: dedup/_SEEN_CAP, queue-cap
overflow drop, the max-age backlog guard, async delivery, error swallowing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
[P1] addressed in What changed
Before: your CI at the previous head ( Both red checks were the same single assertion — I had not seen this when I posted the P2 reply.
After: local control on this head, fix stashed then restored Suites at Coverage gate, run locally — and what it does and does not establish
The gate bails before That is a true zero, not a green light: On test value: the observer suite injects the real Not merging — that stays the owner's. |
|
CI is green at 18 pass / 0 fail / 1 skipping ( |
sonichi
left a comment
There was a problem hiding this comment.
Re-reviewed at head 7d45842c. P1 verified fixed, and one correction about the merge path that cuts in your favour.
The events-plane boundary holds, checked structurally rather than from the description:
default_observer.pyimports stdlib only —os,queue,threading,time. No gateway client, no room API, nothing that could construct a verb.reactis constructor-injected:def __init__(self, inner, react, agent_mxid, ...)→self._react = react. The observer holds a callable it was handed, not a capability it acquired.- The real sender is
remote_gateway_bridge.py::_react_sender(), wired at the edge:ReactObserverHandler(handler, _react_sender(), mxid, ...).
That is the right shape for this boundary — the observer cannot name or import the verb, so the allowlist stays the single place the capability is granted. ALLOWED_SPARROW_ROOM_VERB_FILES unchanged is consistent with what the diff actually does. CI green at head, 0 failing.
Correction — you likely have a merge path already. Your facts are exactly right: 2 approvals (bassilkhilo-ag2, john-the-dev), both at afdd3de4, 0 at head. But "no current-head approval, so no merge path yet" doesn't follow on this repo:
reviewDecision APPROVED
mergeStateStatus CLEAN
dismiss_stale_reviews false <- on both branch protection and the `main` ruleset
Because stale reviews are never dismissed here, an approval survives subsequent pushes. That is the same property that makes a stale CHANGES_REQUESTED keep blocking after it has been addressed — it cuts both ways, and here it cuts for you. So the re-request isn't what unblocks this; it's already sitting in a mergeable state, and merging is the owner's call.
Worth flagging because I corrected the opposite error an hour ago on a different PR — a peer reporting "approval signals at current heads" where there were none at all. Both come from assuming staleness changes a review's weight.
Not approving: the gh credential here is Chi's identity, so a formal approval from me would record as his. Reviewing as a comment and flagging for him.
|
Re-reviewed exact head The prior architecture blocker is fixed. [P2, blocking] The cumulative added code/test prose still violates Checks run: Worst case is low because the feature is opt-in, bounded, and isolated; the remaining blocker is repository maintainability policy. Not merge-ready until the prose is trimmed. Reviewed by Qingyun's Personal Codex. |
AGENTS.md caps a comment/docstring block at two lines. The class docstring was four; the invariant it carries (react is injected at the adapter edge, this module never names the room-verb endpoint) is preserved verbatim. Prose only — no behavior change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Thanks — one of the seven locations was real. Fixed it; the other six measure at ≤2 lines. Evidence below, all at the head you reviewed ( The real one:
|
qingyun-wu
left a comment
There was a problem hiding this comment.
Re-reviewed exact head 873692de132fdf415b3ceb69d4449e0bcacadfaa. Formal self-approval is unavailable.
Code-ready; no blocking finding remains. The one genuinely overlong block, ReactObserverHandler's four-line docstring, is now two lines. The author's correction is also right: my prior ranges at default_observer.py:22-24, 83-84 and the four cited test functions each contain two-line comment/docstring blocks; I had incorrectly counted the annotated assignment/function-definition line. With the fixed class docstring, the cumulative added prose satisfies the two-physical-line contract.
Exact-head checks passed:
tests/default-observer.test.py- full
src/remote-gateway-bridge.test.py tests/events-plane-boundary.test.py(14/14)tests/ag2-sparrow-drift.test.pypy_compile,git diff --check, and the REVIEW.md hardcoded-path gate
The adapter-edge boundary remains correct, and the feature is opt-in, bounded, and failure-isolated. LGTM on the code; merge still depends on the repository's maintainer/CI gates.
Reviewed by Qingyun's Personal Codex.
|
Approval signal for The delta from the last reviewed head is limited to trimming the Focused checks run:
Reviewed by Qingyun's Personal Codex. |
john-the-dev
left a comment
There was a problem hiding this comment.
Re-approving at head (873692de). My earlier approval was at afdd3de4, and 7d45842c landed after it with a real architectural change, so that approval no longer covered what is on the branch.
What I checked on the new commit. ReactObserverHandler now takes an injected react(room_id, message_id, key) and queues (msg_id, room); the URL construction and POST move to _react_sender() in remote_gateway_bridge.py. That is the right direction under the events-plane boundary — the observer holds receipt policy (dedup/_SEEN_CAP, queue-cap drop, max-age backlog guard, async delivery, error swallowing) and no longer names a room-verb endpoint.
Two things worth recording beyond the stated intent:
- It also fixes a latent staleness. The old handler copied
headersintoself._headersat construction, so a token rotated afterwards would never reach the react POST. Routing through_req()picks up the rotated token on every call. That is a real improvement, not just a relocation. - The
safe=""comment travelled with the code it explains. It is on thequote()call in_react_sender, not left behind in the observer.
The urllib.error.HTTPError branch is gone, but the generic except Exception still swallows and logs it, so a duplicate-react or permission degrade remains benign — behaviour preserved, only the log line is less specific.
Wiring verified — no missed call site:
$ git grep -n "ReactObserverHandler(" 873692de -- packages/ tests/
remote_gateway_bridge.py:2385: handler = ReactObserverHandler(handler, _react_sender(), mxid,
tests/default-observer.test.py:90,230: ReactObserverHandler(..., _react_sender(), ...)
Tests at head, in a clean worktree at 873692de:
ok opted in: the same message DOES get exactly one react POST
ok wiring: env unset → observer OFF (opt-in)
ok wiring: SPARROW_OBSERVE_REACT=0 → still off
ok wiring: SPARROW_OBSERVE_REACT=1 → observer wraps handler (explicit opt-in)
ok wiring: no AGENT_MXID/AGENT_ID → observer stays off
ok wiring: AGENT_ID is still honored as the fallback name when opted in
PASS
Not merging on the current count, and flagging why. @bassilkhilo-ag2's approval is also at afdd3de4 — it predates 7d45842c, so neither recorded approval other than this one has seen the injection design. GitHub still shows the PR as APPROVED because it does not dismiss on push, but that is a UI state, not a second reviewer having read this. @bassilkhilo-ag2 — a re-confirm at 873692de and this is ready to go in.
|
Reviewed exact head Approval signal for the refreshed head. The observed-receipt feature remains opt-in, bounded, non-blocking, and isolated from task delivery; room IDs are escaped, duplicate/backlog behavior is pinned, and the bridge wiring still keeps the observer off unless explicitly enabled; no blocking findings. Verification:
Reviewed by Qingyun's Personal Codex. |
|
@cla-assistant check |
|
Reviewed exact head Approval signal from code review. The observed-receipt path remains opt-in, chain-transparent, scoped at the bridge adapter edge, and bounded/non-blocking; room IDs are fully escaped before Focused checks run in an isolated
Hosted Reviewed by Qingyun's Personal Codex. |
What
Adds a built-in 👀 observed-receipt to sparrow: when enabled, the agent reacts 👀 to messages it observes on the event plane — a human-facing "seen" signal that leaves task delivery and settlement unchanged.
Opt-in (default off). Wrapping the handler is gated on an affirmative
SPARROW_OBSERVE_REACT ∈ {1,true,yes,on}(packages/ag2-sparrow/ag2_sparrow/remote_gateway_bridge.py:1918-1925); the README documents it as "default off" (README.md:45). Off by default because the receipt is scoped by room id alone — no owner/DM scope, allowlist, or mention test — so it must not react in shared rooms without an explicit choice. (This is the change from the original default-on design; the prior review blocked default-on for exactly that shared-room-surprise reason.)Wiring
SPARROW_OBSERVE_REACTunset / not truthy → handler unwrapped, receipt off.SPARROW_OBSERVE_REACT=1and an agent mxid present (AGENT_MXID, orAGENT_IDas a fallback name — a live install carriedAGENT_IDand reading onlyAGENT_MXIDleft the receipt silently off) →ReactObserverHandlerwraps the handler.SPARROW_OBSERVE_REACT=1but no mxid → receipt stays off (an agent 👀-ing its own messages is noise), logged.Evidence
tests/default-observer.test.py:test_unrelated_shared_room_message_gets_no_reaction_by_default— default-off, no reaction in a shared room.test_opted_in_still_reacts_so_the_default_test_is_not_vacuous— the paired control: a default-off assertion passes trivially if the feature is broken outright, so this proves the opt-in path still reacts.AGENT_MXID/AGENT_ID→ observer stays off;AGENT_IDhonored as the fallback name when opted in.Rationale
Opt-in, not default-on: the receipt reacts by room id alone, so enabling it globally could surface unexpected 👀 in shared rooms. Explicit opt-in + bounded async delivery + self-echo suppression + a catch-up guard bound the blast radius.