Skip to content

fix: surface participant description in the passive roster (INT-943) - #517

Merged
AlexanderZ-Band merged 6 commits into
mainfrom
basel3/surface-participant-description-in-the-passive-ros-INT-943
Aug 6, 2026
Merged

fix: surface participant description in the passive roster (INT-943)#517
AlexanderZ-Band merged 6 commits into
mainfrom
basel3/surface-participant-description-in-the-passive-ros-INT-943

Conversation

@AlexanderZ-Band

Copy link
Copy Markdown
Collaborator

Summary

  • Surfaces each participant's description in the always-injected passive roster (build_participants_message), so the LLM can route by role without a roster tool call.
  • Closes the gaps a review of this branch surfaced: add_participant()'s tool call and ExecutionContext's participant cache were both silently dropping/losing description on certain paths (see commit body for detail).
  • Hardens the roster line against a self-authored description injecting fake extra roster entries or spoofing the trailing IMPORTANT: instruction line.

Test plan

  • uv run pytest tests/ --ignore=tests/integration/ --ignore=tests/e2e/ -v — 4365 passed, 85 skipped
  • uv run ruff check . && uv run ruff format --check .
  • uv run pyrefly check
  • Live E2E baseline smoke (test_reports_peer_description_from_passive_roster) — not run in this session; recommend running before merge to confirm the live backend's list_agent_chat_participants response actually populates description for agent participants (the Fern ChatParticipant type doesn't declare that field but has extra="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

@linear-code

linear-code Bot commented Aug 3, 2026

Copy link
Copy Markdown

INT-943

@AlexanderZ-Band
AlexanderZ-Band requested a review from a team August 3, 2026 13:18
@AlexanderZ-Band
AlexanderZ-Band force-pushed the basel3/surface-participant-description-in-the-passive-ros-INT-943 branch from 6ec41e7 to e881faa Compare August 5, 2026 10:26
AlexanderZ-Band and others added 4 commits August 6, 2026 09:22
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 amit-gazal-band 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.

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.

Comment thread src/band/runtime/formatters.py Outdated
Comment thread src/band/runtime/participants.py Outdated
@AlexanderZ-Band
AlexanderZ-Band force-pushed the basel3/surface-participant-description-in-the-passive-ros-INT-943 branch from e881faa to adc93d3 Compare August 6, 2026 06:27
…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 amit-gazal-band 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.

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.

Comment thread src/band/runtime/formatters.py
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 amit-gazal-band 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.

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_quoting asserts count('"') == 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.

@AlexanderZ-Band
AlexanderZ-Band added this pull request to the merge queue Aug 6, 2026
Merged via the queue into main with commit d59b0d3 Aug 6, 2026
19 checks passed
@AlexanderZ-Band
AlexanderZ-Band deleted the basel3/surface-participant-description-in-the-passive-ros-INT-943 branch August 6, 2026 08:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants