Skip to content

Commit 445556b

Browse files
committed
feat(buzz-acp): isolate sessions by conversation thread
1 parent 7e34bee commit 445556b

5 files changed

Lines changed: 2951 additions & 793 deletions

File tree

crates/buzz-acp/README.md

Lines changed: 64 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -142,10 +142,19 @@ The gate applies to **all** inbound events — @mentions, DMs, thread replies, a
142142
| Command | Effect |
143143
|---------|--------|
144144
| `!shutdown` | Gracefully exits the harness. |
145-
| `!cancel` | Cancels the current in-flight turn for that channel, if any. |
146-
| `!rotate` | Rotates the ACP session for that channel. If a turn is in-flight, it is cancelled and the channel session is invalidated when the task returns; otherwise the cached idle session is invalidated immediately. The next queued/received event starts a fresh session. |
145+
| `!cancel` | Cancels the current in-flight turn for the conversation the command targets, if any. |
146+
| `!rotate` | Rotates the ACP session for the conversation the command targets. If a turn is in-flight for that conversation, it is cancelled and the session is invalidated when the task returns; otherwise the cached idle session is invalidated immediately. The next queued/received event in that conversation starts a fresh session. |
147147

148-
Use `!cancel` to stop only the current turn; it is a no-op when the channel is idle. Use `!rotate` when you want the next turn in the channel to start from a fresh ACP session, even if the channel is currently idle.
148+
`!cancel` and `!rotate` are **conversation-scoped** (see [Session Scope](#session-scope)):
149+
send the command **as a reply inside a thread** to target that thread's session,
150+
or as a **bare (unthreaded) channel message** to target the channel-level
151+
conversation. A thread-scoped command never cancels or rotates sibling threads
152+
or the channel conversation, and a channel-level command never destroys thread
153+
sessions.
154+
155+
Use `!cancel` to stop only the current turn; it is a no-op when the targeted
156+
conversation is idle. Use `!rotate` when you want the next turn in that
157+
conversation to start from a fresh ACP session, even if it is currently idle.
149158

150159
Owner control commands must be kind:9 stream messages from the owner, must mention this agent with a `p` tag, and are consumed by the harness instead of being forwarded to the agent.
151160

@@ -193,7 +202,55 @@ buzz-acp --agents 2 --heartbeat-interval 300 \
193202

194203
### Shared Identity
195204

196-
All N agents authenticate as the **same Nostr bot identity** — users see one bot regardless of how many agents are running. The same channel is never processed by two agents simultaneously (the queue enforces this). Cross-channel message ordering is not guaranteed when N>1.
205+
All N agents authenticate as the **same Nostr bot identity** — users see one bot regardless of how many agents are running. The same conversation (see [Session Scope](#session-scope)) is never processed by two agents simultaneously (the queue enforces this); different conversations — including different threads in one channel — can run concurrently when N>1. Cross-conversation message ordering is not guaranteed when N>1.
206+
207+
### Session Scope
208+
209+
The harness keys ACP sessions (and queueing, steering, turn counters, and
210+
`!cancel`/`!rotate` targeting) by **conversation**, not just by channel:
211+
212+
- **Unthreaded channel messages** share one channel-level conversation — one
213+
continuous ACP session per channel, as before.
214+
- **Thread replies** in a regular channel are scoped by `(channel, thread
215+
root)`: every thread gets its own isolated ACP session. Replies in the same
216+
thread reuse that thread's session; separate threads in the same channel
217+
never share or pollute each other's sessions, and can be in-flight
218+
simultaneously.
219+
- **Forum posts** (kind 45001) are thread roots: a post scopes to its own
220+
event ID, and comments on it (kind 45003) resolve to the same
221+
conversation, so a forum post and its comment thread share one session
222+
while separate posts in the same channel stay isolated.
223+
- **DMs** always use channel-level continuity — one session per DM
224+
conversation, even for replies with thread tags.
225+
- **Channels joined after startup** resolve and cache their metadata before
226+
the harness opens event delivery. Failed lookups retry while the membership
227+
remains active. The first delivered event therefore scopes normally — DMs
228+
channel-level, regular/forum channels per-thread — without an
229+
event-before-metadata fallback that could split one conversation.
230+
231+
For stream messages the thread root is the canonical NIP-10 `root` marker
232+
from the reply's `e` tags. Note a stream thread's root message is itself an
233+
unthreaded channel message — it runs in the channel-level conversation; the
234+
thread's own session begins with the first reply.
235+
236+
**Stable agent ownership (N > 1):** a conversation's session history lives in
237+
exactly one agent process, so each conversation is pinned to the agent that
238+
serves it. If that agent is busy on other work when new events arrive, the
239+
conversation waits for it (fairness position preserved) instead of being
240+
forked onto another agent. Ownership is released when the session is rotated
241+
or invalidated, or when the owning agent process dies — the next turn then
242+
starts a fresh session on any agent.
243+
244+
**Desktop model switching** is channel-level desired state: a `switch_model`
245+
control frame updates every idle agent holding sessions under the channel,
246+
signals every in-flight conversation under it, and is inherited by any agent
247+
that later picks up a conversation in that channel.
248+
249+
When the agent is removed from a channel, every conversation under that
250+
channel — channel-level and all threads — is drained and its sessions are
251+
invalidated. This is agent conversation routing only: channel identity,
252+
subscriptions, and NIP-29 authorization remain keyed by the `h`-tag channel
253+
UUID.
197254

198255
### Heartbeat Semantics
199256

@@ -242,12 +299,12 @@ Forum event kinds:
242299

243300
1. **Startup** — Spawns N agent subprocesses (default 1), sends ACP `initialize` to each, connects to the relay with NIP-42 auth.
244301
2. **Channel discovery** — Queries the relay REST API for accessible channels, subscribes to each.
245-
3. **Event loop** — Listens for @mention events (kind 9 with the agent's pubkey in a `#p` tag). Events queue per channel.
246-
4. **Prompting** — When events are pending and no prompt is in flight for that channel, drains all queued events for the oldest channel into a single batched prompt via ACP `session/prompt`.
302+
3. **Event loop** — Listens for @mention events (kind 9 with the agent's pubkey in a `#p` tag). Events queue per conversation (channel-level or thread — see [Session Scope](#session-scope)).
303+
4. **Prompting** — When events are pending and no prompt is in flight for that conversation, drains all queued events for the oldest conversation into a single batched prompt via ACP `session/prompt`. Events from different conversations are never batched together.
247304
5. **Agent response** — The agent processes the prompt and uses the Buzz CLI (`send_message`, `get_messages`, etc.) to interact with Buzz.
248305
6. **Recovery** — If the agent crashes, the harness respawns it. If the relay disconnects, the harness reconnects with a `since` filter to avoid missing events.
249306

250-
Each channel has at most one prompt in flight. Multiple channels can be processed concurrently when agents > 1.
307+
Each conversation has at most one prompt in flight. Multiple conversations — including multiple threads within one channel — can be processed concurrently when agents > 1.
251308

252309
> **Note:** On startup, the harness replays all unprocessed @mentions since the last run. Expect a burst of activity if there are stale events in the channel.
253310

0 commit comments

Comments
 (0)