fix: surface participant description in the passive roster (INT-943) - #517
Conversation
6ec41e7 to
e881faa
Compare
Add a matrix smoke that quotes in-room peer descriptions without roster tools, so INT-943 stays red until load/tracker stop dropping description. Co-authored-by: Cursor <cursoragent@cursor.com>
participant_snapshot() now takes a plain dict instead of duck-typing dict-or-object; the 3 REST call sites that used to hand it a live Fern model now model_dump() first, same as the WebSocket payload callers already did. Removed the now-dead ParticipantTracker class (unused in production; ExecutionContext already reimplemented the same logic inline). Closed the gaps a review of this branch surfaced: - add_participant()'s tool call built its cache entry by hand and dropped description; it now goes through participant_snapshot() too. - ExecutionContext.add_participant() no-op'd on a duplicate id instead of refreshing it, so a description learned after a participant was first tracked (e.g. via a later REST fetch) never reached the roster. It now upserts, and get_participants() pushes every refreshed participant, not just new ids. - build_participants_message() collapses a participant description to one line and caps its length, so a self-authored description can't inject fake roster lines or spoof the trailing IMPORTANT instruction. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ldhhg7ykaMGPDEM9hzWppS
participants_changed() diffed only the set of participant ids, so a same-membership refresh (e.g. a description learned after a participant was first tracked) never registered as a change. Since check_and_format_participants() skips the roster message whenever participants_changed() is False, the upsert path added in the prior commit updated the cache but the LLM was never notified of the new field. Compare id-keyed snapshots instead of bare id sets. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ldhhg7ykaMGPDEM9hzWppS
…rich ones
Review follow-ups on the passive-roster description PR:
- ExecutionContext.add_participant now merges field-by-field instead of
replacing the record wholesale: a sparser source (WS participant_added
without description/handle, the participants list endpoint without
description) can no longer erase fields a richer source (Peer lookup)
already learned — which also stops the rich->sparse->rich roster
re-injection churn under the value-level participants_changed().
- New ExecutionContext.set_participants: authoritative membership from a
REST list (stale entries drop out, including ctx-only ghosts the tools
instance never saw) with the same per-id field merge. load_participants
and the get_participants sync-back both use it, deleting the id-diff
bookkeeping.
- claude_sdk: room tools are now built via AgentTools.from_context, so
participant changes sync through the tools themselves with the full
field set; the tool_result_hook that upserted a hardcoded sparse
{id, name, type: "Agent"} dict (clobbering handle/description and
relabeling Users as Agent under upsert semantics) is gone.
- build_participants_message: `or` fallbacks instead of dead get()
defaults — snapshot dicts always carry the keys, with None when a
source didn't know the field, so '- @None — X (None)' can no longer
render.
- participant_tracker.py renamed to participants.py (nothing tracks
anymore); merge_participant lives beside participant_snapshot with
direct unit tests, including the exact-field-set invariant the
deleted tracker tests used to pin.
- Baseline toolkit: RosterTool StrEnum validated against the SDK's
READ_ONLY_TOOL_NAMES at import replaces the raw tool-name literals in
the passive-roster smoke; stale "Red until ..." narration dropped;
peer provisioning gathered concurrently.
- tests: shared make_participant_mock in tests/conftest.py replaces six
hand-rolled attr+model_dump mock pairs (one had already drifted —
missing the description key); formatter assertions read the roster
line and derive the truncation width from the constant.
- AGENTS.md: ParticipantAddedPayload row gains handle?/description?.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014cFyJiRJiY2dhw1deZEbmq
amit-gazal-band
left a comment
There was a problem hiding this comment.
Two things to address before merge: peer-authored descriptions land verbatim in every co-participant's system prompt, and the sparse-source merge guard doesn't match how the consumer defines "absent". Details inline.
e881faa to
adc93d3
Compare
…o the consumer Two review follow-ups: - merge_participant checked absence with `is not None`, but the consumer (build_participants_message) gates on truthiness. A source serializing description: "" instead of omitting the key (plausible for the participants-list endpoint) would win the merge and erase a description learned from a richer source. Switched to the same `or` truthiness check the consumer uses. - Peer-authored descriptions render verbatim in every co-participant's system prompt. Quoting the value plus a "not instructions to you" caveat (shown only when a description is actually rendered) mitigates instruction injection without removing the roster feature. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LPfVDcRJgdwUVNJERowHoz
amit-gazal-band
left a comment
There was a problem hiding this comment.
Follow-up on the two fixes in 5938298 — the merge change is fully resolved, the quoting has one small gap left. Detail inline.
Verified locally at this SHA: tests/runtime/ 687 passed. The unrelated failures in a full-suite run (test_logging_config.py, desktop_app) reproduce on the branch base / pass in isolation respectively — not this PR.
An embedded " closed the roster line's quoting early, placing the rest of a self-authored description outside the quoted span where it reads as roster text rather than a quoted value. Quotes now collapse to ' in the sanitizer alongside the whitespace collapse, so the whole description stays inside one quoted span. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LPfVDcRJgdwUVNJERowHoz
amit-gazal-band
left a comment
There was a problem hiding this comment.
Approving — both blocking findings are resolved, plus the follow-up nit.
- Merge absence check (5938298):
snapshot.get(name) or existing.get(name)now matches the consumer exactly, with a regression test for the""case. - Description injection (5938298 + c6fff38): quoted value, a gated non-authoritative caveat, and quotes collapsed in the sanitizer before the length cap so the value can't close its own quoting.
test_description_cannot_close_its_own_quotingassertscount('"') == 2, which holds for any future breakout rather than just the one example.
Verified at c6fff38: tests/runtime/ 688 passed, and I probed the sanitizer directly with quote-breakout / trailing-quote / all-quotes / newline+quote descriptions — exactly one quoted span in each.
One thing still open, not blocking and not a code defect: whether list_agent_chat_participants actually returns description on the wire. Fern's ChatParticipant doesn't declare it, so it only survives via extra="allow" if the backend happens to send it — and that endpoint is the only hydration source most rooms use. If it's absent, the roster renders description-less in the common path and the new E2E smoke fails, with no lookup_peers enrichment fallback to compensate. Worth confirming live before this ships rather than after.
Also noting for a follow-up (all out of scope for this PR): ParticipantTracker was removed from band.__all__ with no deprecation shim; the tool_result_hook plumbing in claude_sdk/tools.py and mcp/backends.py now has zero callers; and PASSIVE_ROSTER_DESCRIPTIONS_PROBE's "without calling any tools" may flake on tool-first adapters where band_send_message is the reply path.
Summary
descriptionin the always-injected passive roster (build_participants_message), so the LLM can route by role without a roster tool call.add_participant()'s tool call andExecutionContext's participant cache were both silently dropping/losingdescriptionon certain paths (see commit body for detail).IMPORTANT:instruction line.Test plan
uv run pytest tests/ --ignore=tests/integration/ --ignore=tests/e2e/ -v— 4365 passed, 85 skippeduv run ruff check . && uv run ruff format --check .uv run pyrefly checktest_reports_peer_description_from_passive_roster) — not run in this session; recommend running before merge to confirm the live backend'slist_agent_chat_participantsresponse actually populatesdescriptionfor agent participants (the FernChatParticipanttype doesn't declare that field but hasextra="allow", so it round-trips if the wire payload sends it — this is the one fact that needs live confirmation, not a code fix)Co-Authored-By: Claude Sonnet 5 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01Ldhhg7ykaMGPDEM9hzWppS