Skip to content

feat(chat): rooms instead of threads — multi-agent, multi-human - #5254

Open
vibegui wants to merge 5 commits into
mainfrom
vibegui/agent-hierarchy-mind-dump
Open

feat(chat): rooms instead of threads — multi-agent, multi-human#5254
vibegui wants to merge 5 commits into
mainfrom
vibegui/agent-hierarchy-mind-dump

Conversation

@vibegui

@vibegui vibegui commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Rooms, not threads — and more than one agent in them

Two distinctions drive this branch:

  1. The agent is the unit of improvement, not the org. You hire a person and they learn skills, remember, hold goals, keep responsibilities on a rhythm. Memory / Skills / Loops are organs of an agent, not flat platform layers.
  2. The thread is not the paradigm — the room is. A top-level room is a repo or a big subject. You open it and see messages from other agents and other people in your org, and you all collaborate there.

This PR ships the second one and the multi-participant plumbing it needs. Research behind it (Buzz) is at the bottom.


How to verify (5 minutes, no AI provider needed)

bun install
bun run check && bun run lint          # 0 TS errors, 0 lint errors
bun test packages/shared/src/entities.test.ts \
         apps/api/src/harnesses/decopilot/room-transcript.test.ts   # 15 pass

room-transcript.test.ts is the one to read first — it's the whole multi-agent correctness argument in 9 cases, no mocks, no DB.

In the app (bun run dev):

  1. Sidebar opens grouped by Rooms — one collapsible group per agent, threads nested inside.
  2. Open a thread, type @SomeOtherAgent do Xthat agent answers, in this thread, labelled with its avatar and name.
  3. Click the person icon in the chat header → the thread becomes a Shared room. A teammate can now post in it; each message shows who sent it.
  4. Toggle it back → the teammate's composer returns to read-only.

E2E (packages/e2e/tests/chat-room.spec.ts): three cases over the real route — teammate refused from a personal chat (403, nothing written), teammate admitted to a shared room (202, message row lands), a second agent takes a turn without the room changing its primary agent.

Disclosure: I could not get a green local e2e run. The suite fails at signUpViaApi (FAILED_TO_CREATE_USER) in this workspace, and an existing spec (decopilot-messages.spec.ts) fails identically on the same box — so it's the local environment, not this spec. The file type-checks (bun run --cwd=packages/e2e check, 0 errors on it) and follows the pattern of the specs next to it, but it has not been observed passing. Please treat CI's run as the first real signal.


What's here, and why it's shaped this way

1. Sidebar: rooms instead of a thread list

A room is an agent — a code agent is its repo, a plain agent its subject. groupThreadsByVirtualMcp already existed and was unused; this wires it up as the default view. List and Status stay in the filter popover. No schema change.

2. Several agents answering in one thread

@agent used to expand into a [DELEGATE TO AGENT …] use the subtask tool instruction — so the current agent answered on the other one's behalf and the room never heard the agent itself. Now the mention hands the turn to that agent.

Why no migration: dispatch-run already resolved the agent from input.agent.id on the request and never read threads.virtual_mcp_id. The single-agent-ness was a client convention (the ?virtualmcpid= URL param). The mention is recorded in metadata.agent on the user message — thread_messages.metadata is free-form JSON that the history read path already preserves, so authorship needed no column. Migration 057 deliberately collapsed agent_ids into a singular virtual_mcp_id; this does not re-add an array. The thread keeps one primary agent (the room's), and authorship lives per message, which is where it belongs.

The correctness core is buildRoomTranscript (apps/api/src/harnesses/decopilot/room-transcript.ts). The model context carries only role. Without this, agent B reads agent A's replies as its own past words — it contradicts itself, apologises for things it never said, "continues" work it never began. So history is rewritten for whoever is about to speak:

  • own turns stay assistant;
  • another agent's turns become user messages labelled [Mario]: …;
  • humans get named too, but only once more than one human has spoken (a 1:1 thread reads exactly as today);
  • tool parts are flattened out — a tool call can't ride on a user message, and an orphaned tool result breaks strict providers;
  • a tool-only turn becomes (worked on this without replying) rather than empty text, which some providers reject;
  • threads with no metadata.agent — every thread written before this — pass through untouched.

Applied in context-loader.ts, one seam, so every decopilot run gets it.

3. Shared rooms (multi-human)

Threads were writable by their creator alone — right for a personal chat, wrong for a room. Opt-in metadata.shared.

Why a flag and not "every thread is a room": the owner-only rule was added deliberately (#4230 made teammates' threads read-only). Flipping it wholesale would silently undo that for everyone. A room is now something you open, and a personal chat still behaves exactly as it did.

Why one predicate: canWriteToThread in packages/shared is called by the route, by dispatch-run, and by the composer. Three call sites, one rule — they cannot drift, and the composer can never offer a write the server will reject. Unknown actor (session still loading) is denied rather than assumed; a non-boolean shared is ignored rather than trusted.

Why the guard moved: it lived in dispatch-run, inside the DBOS workflow — so an unauthorized post returned 202 and died asynchronously after the user message had been persisted. It now runs in the route's validate(), where the thread row and caller are already in hand: 403 on the POST, nothing written. dispatch-run keeps the check as defense in depth. Doing it before model resolution is also what lets the e2e prove the contract with no AI provider configured.

4. UI

  • RoomToggle in the chat panel header flips personal ↔ shared. Owner-only, and hidden until the row has a created_by to compare against so an optimistic row can't act on the wrong thread. (There was no thread-actions menu anywhere to hang this on — rename exists in the store and is called from nowhere — so it's a single icon button rather than a new dropdown surface.)
  • Author on user messages, shown only in a shared room. With the agent label from commit 2, a room now answers both halves of "who said this": which human, which agent.

Trade-offs and what I deliberately did not do

  • No room_members table. metadata.shared is a 1-bit stand-in: a room is open to the whole org or to nobody. Per-room membership, invites and scoped guests are the real model (see the Buzz notes) and a much larger change.
  • No presence. You can't see who's in the room or which agent is working right now.
  • One agent per turn. A turn is one run; the last @ mention wins. No parallel replies from two agents to the same message.
  • Threads nest one level (room → threads), not N.
  • Attribution is derived, not stored on the assistant row. The author of an assistant message is the agent the preceding user message addressed. Cheap and correct for this flow; a real author column is the honest fix once agents are principals.
  • deriveOthersThreadLabel (the sandbox auto-start gate) still uses raw ownership, not canWriteToThread — booting a sandbox on someone else's branch is a different decision from posting a message, and I didn't want to loosen it by accident.

Research: what Buzz teaches us

Read alongside Buzz (VISION.md, VISION_PROJECTS.md, VISION_AGENT.md, migrations/0001_initial_schema.sql) and the Raft write-up. Buzz is a Slack-like workspace where humans and agents are members of the same channels; a branch is a channel and that channel is the PR + CI dashboard + discussion.

Where we're already aligned

Buzz Studio
events — one signed, partitioned append-only log thread_message_parts — stream-of-record with fold
subscriptions + delivery_log, at-least-once event-bus, CloudEvents, at-least-once + backoff
workflows, workflow_runs, scheduled_workflow_fires automations (cron/event/webhook) on DBOS — our durable runtime is stronger: fences, resume, projection
community = tenant, URL selects it org = tenant, /api/:org/...
thread_metadata (parent/root/depth), N levels threads inside a room, 1 level

Where we're stronger, and shouldn't copy them

MCP as the product: bindings, virtual MCPs, tool aggregation, credential vault, OAuth proxy, an AI-provider layer with tiers and credits, multiple harnesses (claude-code / codex / decopilot) with sandbox + branch + preview. Buzz uses MCP as agent plumbing, not as a control plane. That layer is our asset.

Where we're fundamentally wrong

1. An agent is not a principal — it's a connection. In Buzz an agent is a row in the same users table as humans: its own keypair, role='bot' in channel_members, and agent_owner_pubkey so it inherits its owner's access — remove the human and every agent of theirs loses access instantly. In Studio an agent is a connections row of type VIRTUAL: a tool, not a subject. Hence it can't be a member, can't have presence, can't claim work, can't hold authority. Most of this PR is a workaround for that missing principal model. This is the deep fix.

2. There is no room membership. Our access is org-wide plus thread-owner. There is no "who is in this room" — so no participant list, no invite, no scoped guest, no per-room privacy. metadata.shared is a 1-bit stand-in for a room_members table. In Buzz, channel membership is the only gate, and it's the same gate for humans, agents and guests.

3. A message has a role, not an author. That's problem 1 expressed in the schema. buildRoomTranscript is a patch over a missing author column. In Buzz authorship is cryptographic — every event is signed by a pubkey.

4. No presence. Buzz shows "2s (2 agents)" working in a channel. A room without presence doesn't feel like a room; it feels like an inbox.

5. Agents don't watch the room. In Buzz an agent subscribes to a channel — that's how "an agent watching the incident channel answers on its own" happens at all. In Studio an agent acts only when a human sends a turn or a cron fires. We already have the event bus; it just isn't wired to conversation. Cheapest gap to close, highest return.

6. One log, three lenses — we have N models and N screens. Stream / Forum / Workflow are renderings of the same event log, which is why a Buzz channel can be the PR and the CI dashboard and the discussion at once. Our board, automations, PRs and threads are separate models: nothing to search, replay or subscribe to in one place.

7. No attention model. Buzz defaults to zero notifications, urgency only in DMs. A room without an attention policy becomes noise — the Raft piece reaches the same conclusion from the other direction ("what changes is what reaches you").

Suggested order

agent-as-principal (1 + 3) → room membership (2) → agent subscribes to the room (5) → presence (4). Items 6 and 7 are strategy, not sprint. None of this needs cryptographic identity: the lesson from Buzz is the model of subjects and belonging, not Nostr.

🤖 Generated with Claude Code

vibegui and others added 5 commits July 26, 2026 22:24
The sidebar listed threads flat (or by status), which reads as an inbox
rather than a workspace. Group them by agent instead: a room is an agent —
a code agent is its repo, a plain agent its subject — and the threads inside
it are the conversations held there.

No schema change: `groupThreadsByVirtualMcp` already existed and was unused,
and threads already carry `virtual_mcp_id`. Rooms become the default view;
List and Status stay available in the filter popover.

Co-Authored-By: Claude <noreply@anthropic.com>
An "@agent" mention used to expand into a "[DELEGATE TO AGENT …] use the
subtask tool" instruction, so the current agent answered on the other agent's
behalf and the room never heard the agent itself. Now the mention hands the
turn to that agent: it answers in the thread, as itself.

The backend was already per-turn — `dispatch-run` resolves the agent from
`input.agent.id` on the request and never reads `thread.virtual_mcp_id` — so
this needs no migration and no new column. The mention is recorded as
`metadata.agent` on the user message, which is also what later readers use to
tell who spoke.

The correctness core is `buildRoomTranscript`: the model context carries only
`role`, so without it an agent reads another agent's replies as its own past
words and starts contradicting itself. It rewrites the history for whoever is
about to speak — own turns stay `assistant`, another agent's become `user`
messages labelled `[Name]: …`, humans get named once more than one has
spoken. Tool parts are flattened out (a tool call can't ride on a `user`
message, and an orphaned tool result breaks strict providers). Threads with no
`metadata.agent` — every thread written before this — pass through untouched.

`build-improve-prompt-doc` relied on the delegation directive; its test now
asserts the routing instead of the removed text.

Co-Authored-By: Claude <noreply@anthropic.com>
Threads were writable by their creator alone, which is right for a personal
chat and wrong for a room. Add an opt-in `metadata.shared`: a shared room
accepts posts from any member of the organization, a personal chat stays
owner-only — so this does not undo the deliberate rule that a teammate's chat
is read-only.

Both guards (the API's dispatch check and the composer's read-only state) now
call the same `canWriteToThread` predicate, so the UI cannot offer a write the
server will reject. Org membership is already proven upstream by the
org-scoped route and the org-scoped thread fetch; the predicate only decides
owner-vs-room. An unknown actor (session still loading) is denied rather than
assumed.

The toggle has no UI entry point yet — rooms can only be opened through the
thread API until one is added.

Co-Authored-By: Claude <noreply@anthropic.com>
The write guard lived in `dispatch-run`, which runs inside the DBOS workflow
— so posting to someone else's chat returned 202 and then died asynchronously,
after the user message had already been persisted. Move the check into the
route's `validate()`, where the thread row and the caller are both already in
hand: an unauthorized post is now a 403 on the POST with nothing written.

`dispatch-run` keeps the same check as defense in depth for callers that reach
it by another path.

Placing it before model resolution also makes the contract testable without an
AI provider, which is what the new e2e spec relies on: a teammate is refused
from a personal chat (403, no message row) and admitted to a shared room (202,
message row lands), and a second agent can take a turn in a thread the first
one opened without the room changing its primary agent.

Co-Authored-By: Claude <noreply@anthropic.com>
Shared rooms were enforced end to end but unreachable: nothing in the UI could
set `metadata.shared`, and a room full of people rendered every message the
same way.

- `RoomToggle` in the chat panel header flips the current thread between a
  personal chat and a shared room. Owner-only — a teammate can already read the
  thread, but who may write to it is the owner's call — and it stays hidden
  until the row has a `created_by` to compare against, so an optimistic row
  can't show a control that would act on the wrong thread.
- A user message now carries its sender's avatar and name, but only in a shared
  room: in a personal chat every message is yours and the name is noise.

Together with the agent label added earlier, a room now shows both halves of
"who said this" — which human, which agent.

Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant