@zooid/transport-matrix 0.11.0 → 0.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/transport.ts CHANGED
@@ -5,6 +5,7 @@ import type { AgentEvent, ContentBlock } from '@zooid/acp-client'
5
5
  import { MatrixClient } from './matrix-client.js'
6
6
  import { BotPool } from './bot-pool.js'
7
7
  import { route, isMediaMsgtype, type AgentBinding, type ThreadState } from './router.js'
8
+ import { sessionKeyFor, composeHandoffKey } from './session-keys.js'
8
9
  import { stripMention, extractMentions } from './mentions.js'
9
10
  import { toToolCallBody, toUpdateBody, toPlanBody, toAvailableCommandsBody, toErrorBody } from './event-encoders.js'
10
11
  import { classify } from '@zooid/acp-client'
@@ -536,8 +537,26 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
536
537
  return
537
538
  }
538
539
  console.log(`[matrix] inbound dev.zooid.session_reset in ${evt.room_id} thread=${threadRoot}`)
540
+ // [[ZOD071]]: a thread's sessions are the thread-level one plus one per
541
+ // handoff arc — end them all. Reset events aren't m.room.message, so
542
+ // the self-heal rebuild above doesn't cover them; rebuild here if the
543
+ // daemon restarted since the arcs were minted.
544
+ if (!threadStates.has(threadRoot) && evt.room_id) {
545
+ try {
546
+ threadStates.set(
547
+ threadRoot,
548
+ await rebuildThreadState(client, evt.room_id, threadRoot, bindings),
549
+ )
550
+ } catch (err) {
551
+ console.warn(`[matrix] failed to rebuild threadState for reset ${threadRoot}:`, err)
552
+ }
553
+ }
554
+ const st = threadStates.get(threadRoot)
539
555
  for (const a of bindings) {
540
556
  agents.endSession(a.name, threadRoot)
557
+ for (const arc of st?.handoffs[a.name] ?? []) {
558
+ agents.endSession(a.name, composeHandoffKey(threadRoot, arc))
559
+ }
541
560
  }
542
561
  // NB: keep threadStates intact. Per ZOD039 § /clear, only the agent's
543
562
  // session memory is wiped — thread-routing state (participants /
@@ -670,7 +689,7 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
670
689
  if (matches.length > 0 && promotedRoot) {
671
690
  let st = threadStates.get(promotedRoot)
672
691
  if (!st) {
673
- st = { participants: [], rootMentions: [], callers: {} }
692
+ st = { participants: [], rootMentions: [], callers: {}, handoffs: {} }
674
693
  threadStates.set(promotedRoot, st)
675
694
  }
676
695
  const msgMentions = new Set(extractMentions(evt as never))
@@ -679,7 +698,17 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
679
698
  if (!msgMentions.has(a.userId)) continue
680
699
  if (!st.rootMentions.includes(a.name)) st.rootMentions.push(a.name)
681
700
  // Call edge: the (agent) sender is the caller of every agent it @mentions.
682
- if (senderAgent && a.name !== senderAgent.name) st.callers[a.name] = senderAgent.name
701
+ if (senderAgent && a.name !== senderAgent.name) {
702
+ st.callers[a.name] = senderAgent.name
703
+ // [[ZOD071]] handoff arc: every agent→agent call opens a fresh
704
+ // session for the callee, keyed by this call event. Recorded before
705
+ // runTurn dispatch so sessionKeyFor resolves the just-minted arc.
706
+ // Deduped: homeservers may redeliver a transaction.
707
+ if (evt.event_id) {
708
+ const arcs = (st.handoffs[a.name] ??= [])
709
+ if (!arcs.includes(evt.event_id)) arcs.push(evt.event_id)
710
+ }
711
+ }
683
712
  }
684
713
  }
685
714
  for (const a of matches) {
@@ -689,7 +718,7 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
689
718
  if (!promotedRoot) return
690
719
  let st = threadStates.get(promotedRoot)
691
720
  if (!st) {
692
- st = { participants: [], rootMentions: [], callers: {} }
721
+ st = { participants: [], rootMentions: [], callers: {}, handoffs: {} }
693
722
  threadStates.set(promotedRoot, st)
694
723
  }
695
724
  if (st.participants.at(-1) !== a.name) st.participants.push(a.name)
@@ -770,10 +799,14 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
770
799
  if (!evt.room_id || !evt.event_id) return
771
800
  const inbound = inboundThreadRoot(evt)
772
801
  // Agent-promotion: top-level inbound becomes a thread root via the agent's
773
- // first reply. sessionKey is always a thread root id, never the room id.
802
+ // first reply.
774
803
  const threadRoot = inbound ?? evt.event_id
775
- const sessionKey = threadRoot
776
- const sessionId = await agents.ensureSession(agent.name, sessionKey, evt.room_id)
804
+ // [[ZOD071]]: the session key is the agent's current handoff arc when it
805
+ // has one, else the thread-level key. The raw threadRoot still travels
806
+ // separately: outbound events relate to it, and it is the context ref so
807
+ // zooid_get_history reads the real thread.
808
+ const sessionKey = sessionKeyFor(agent.name, threadRoot, threadStates.get(threadRoot))
809
+ const sessionId = await agents.ensureSession(agent.name, sessionKey, evt.room_id, threadRoot)
777
810
  sessions.set(sessionId, { agent, roomId: evt.room_id, threadRoot })
778
811
  buffers.set(sessionId, '')
779
812
  bufferMessageIds.delete(sessionId)
@@ -836,6 +869,7 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
836
869
  await agents.prompt(agent.name, {
837
870
  threadId: sessionKey,
838
871
  channelId: evt.room_id,
872
+ contextThreadId: threadRoot,
839
873
  content: [...blocks, { type: 'text', text: fullPromptText }],
840
874
  })
841
875
  // Drain: the prompt promise resolves on the stopReason response, but
@@ -934,7 +968,7 @@ export async function rebuildThreadState(
934
968
  rootEventId: string,
935
969
  bindings: AgentBinding[],
936
970
  ): Promise<ThreadState> {
937
- const state: ThreadState = { participants: [], rootMentions: [], callers: {} }
971
+ const state: ThreadState = { participants: [], rootMentions: [], callers: {}, handoffs: {} }
938
972
  // Impersonate an agent that's actually a member of this room (AS reads
939
973
  // require room membership). Falling through to the first binding would
940
974
  // 403 if that agent never joined the target room.
@@ -949,7 +983,11 @@ export async function rebuildThreadState(
949
983
  for (const a of bindings) {
950
984
  if (!rootMentions.has(a.userId)) continue
951
985
  if (!state.rootMentions.includes(a.name)) state.rootMentions.push(a.name)
952
- if (rootSenderAgent && a.name !== rootSenderAgent.name) state.callers[a.name] = rootSenderAgent.name
986
+ if (rootSenderAgent && a.name !== rootSenderAgent.name) {
987
+ state.callers[a.name] = rootSenderAgent.name
988
+ const arcs = (state.handoffs[a.name] ??= [])
989
+ if (!arcs.includes(rootEventId)) arcs.push(rootEventId)
990
+ }
953
991
  }
954
992
  }
955
993
 
@@ -963,10 +1001,17 @@ export async function rebuildThreadState(
963
1001
  const mentions = new Set(extractMentions(ev as never))
964
1002
  const evSender = (ev as { sender?: string }).sender
965
1003
  const evSenderAgent = evSender ? bindings.find((b) => b.userId === evSender) : undefined
1004
+ const evId = (ev as { event_id?: string }).event_id
966
1005
  for (const a of bindings) {
967
1006
  if (!mentions.has(a.userId)) continue
968
1007
  if (!state.rootMentions.includes(a.name)) state.rootMentions.push(a.name)
969
- if (evSenderAgent && a.name !== evSenderAgent.name) state.callers[a.name] = evSenderAgent.name
1008
+ if (evSenderAgent && a.name !== evSenderAgent.name) {
1009
+ state.callers[a.name] = evSenderAgent.name
1010
+ if (evId) {
1011
+ const arcs = (state.handoffs[a.name] ??= [])
1012
+ if (!arcs.includes(evId)) arcs.push(evId)
1013
+ }
1014
+ }
970
1015
  }
971
1016
  const type = (ev as { type?: string }).type
972
1017
  if (type === 'm.room.message' && evSender) {