@zooid/transport-matrix 0.13.0 → 0.14.0

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/router.ts CHANGED
@@ -50,6 +50,11 @@ export interface ThreadState {
50
50
  handoffs: Record<string, string[]>
51
51
  }
52
52
 
53
+ export interface TaskThreadContext {
54
+ assignee: string
55
+ isRoot: boolean
56
+ }
57
+
53
58
  interface MaybeEvent {
54
59
  type?: string
55
60
  room_id?: string
@@ -71,6 +76,7 @@ export function route(
71
76
  event: MaybeEvent,
72
77
  agents: AgentBinding[],
73
78
  threadStates?: Map<string, ThreadState>,
79
+ task?: TaskThreadContext,
74
80
  ): RouteMatch[] {
75
81
  if (event.type !== 'm.room.message') return []
76
82
  if (!event.content?.msgtype) return []
@@ -81,8 +87,27 @@ export function route(
81
87
  const threadState = threadRoot ? threadStates?.get(threadRoot) : undefined
82
88
 
83
89
  for (const a of agents) {
84
- if (event.sender === a.userId) continue
85
90
  if (!a.rooms.some((r) => r.alias === event.room_id)) continue
91
+ if (task?.isRoot) {
92
+ if (a.name === task.assignee) matches.push(a)
93
+ continue
94
+ }
95
+ if (event.sender === a.userId) continue
96
+ if (task) {
97
+ if (mentions.has(a.userId)) {
98
+ matches.push(a)
99
+ continue
100
+ }
101
+ const senderAgent = agents.find((x) => x.userId === event.sender)
102
+ if (senderAgent) {
103
+ // A delegated task returns at an invocation terminal boundary, never
104
+ // because a callee happened to post progress prose.
105
+ continue
106
+ } else if (a.name === task.assignee) {
107
+ matches.push(a)
108
+ }
109
+ continue
110
+ }
86
111
  if (a.trigger === 'any') {
87
112
  matches.push(a)
88
113
  continue
@@ -100,7 +125,7 @@ export function route(
100
125
  // sender (its caller), never to a callee. Directional continuation
101
126
  // keeps agent↔agent handoffs from looping — the call graph is a tree
102
127
  // rooted at the human, so returns only ever walk up.
103
- if (threadState.callers[senderAgent.name] === a.name) matches.push(a)
128
+ if (isReturnRoute(event, a, agents, threadState)) matches.push(a)
104
129
  } else {
105
130
  // Human (or non-agent) follow-up: continue with the most-recent-posting
106
131
  // agent, or inherit the root mention if no agent has posted yet.
@@ -115,3 +140,54 @@ export function route(
115
140
  }
116
141
  return matches
117
142
  }
143
+
144
+ /**
145
+ * True when routing `event` to `agent` is a *return* — a callee's reply
146
+ * bubbling up to the agent that called it — rather than a fresh call or a
147
+ * human follow-up. A callee may address its existing caller explicitly and it
148
+ * is still a return.
149
+ *
150
+ * The transport defers returns to the sender's turn boundary. An agent turn
151
+ * posts one `m.room.message` per buffered chunk (every tool call forces a
152
+ * flush), so treating each chunk as a return woke the caller once per chunk
153
+ * and the two agents read as re-triggering each other. See [[ZOD039]]
154
+ * § Implicit triggers → Directional continuation.
155
+ */
156
+ export function isReturnRoute(
157
+ event: MaybeEvent,
158
+ agent: AgentBinding,
159
+ agents: AgentBinding[],
160
+ threadState: ThreadState | undefined,
161
+ ): boolean {
162
+ if (!threadState || agent.trigger !== 'mention') return false
163
+ const sender = agents.find((x) => x.userId === event.sender)
164
+ if (!sender || sender.name === agent.name) return false
165
+ // Addressing the existing caller explicitly does not reverse the call edge:
166
+ // it is still the callee returning control. This matters for agents that
167
+ // naturally prefix their final answer with `@caller`; treating that as a new
168
+ // call creates the exact A ↔ B cycle directional continuation prevents.
169
+ return threadState.callers[sender.name] === agent.name
170
+ }
171
+
172
+ /**
173
+ * True when recording `callee`’s caller as `caller` would put a cycle in the
174
+ * call graph — i.e. `callee` is already an ancestor of `caller`. The graph has
175
+ * to stay a tree rooted at the human, because `route` walks it upward on every
176
+ * return; a 2-cycle (A calls B, B @mentions A back) would bounce forever.
177
+ * A mention that would close a cycle is a return, not a call, so it routes but
178
+ * records no edge.
179
+ */
180
+ export function wouldCycleCallers(
181
+ callers: Record<string, string>,
182
+ callee: string,
183
+ caller: string,
184
+ ): boolean {
185
+ const seen = new Set<string>()
186
+ let cursor: string | undefined = caller
187
+ while (cursor !== undefined) {
188
+ if (cursor === callee || seen.has(cursor)) return true
189
+ seen.add(cursor)
190
+ cursor = callers[cursor]
191
+ }
192
+ return false
193
+ }
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { evaluateCompletion } from './task-completion.js'
3
+
4
+ const base = { agent: 'worker', threadId: '$root', outstanding: 0, awaitingHuman: 0 } as const
5
+
6
+ describe('evaluateCompletion', () => {
7
+ it('uses a summary only after all work has returned', () => {
8
+ expect(evaluateCompletion({ ...base, stopReason: 'end_turn', summary: 'done', outstanding: 1 }))
9
+ .toEqual({ decision: 'stay_open', reason: 'outstanding_handoff' })
10
+ expect(evaluateCompletion({ ...base, stopReason: 'end_turn', summary: 'done' }))
11
+ .toMatchObject({ decision: 'finish', completion: { status: 'complete', output: { text: 'done' } } })
12
+ })
13
+ it('makes cancellation and limits terminal even with outstanding work', () => {
14
+ expect(evaluateCompletion({ ...base, stopReason: 'cancelled', outstanding: 1 }))
15
+ .toMatchObject({ decision: 'finish', completion: { status: 'cancelled' } })
16
+ expect(evaluateCompletion({ ...base, stopReason: 'max_tokens', outstanding: 1 }))
17
+ .toMatchObject({ decision: 'finish', completion: { status: 'partial', reason: 'max_tokens' } })
18
+ })
19
+ it('does not report an empty successful result', () => {
20
+ expect(evaluateCompletion({ ...base, stopReason: 'end_turn' }))
21
+ .toMatchObject({ decision: 'finish', completion: { status: 'failed', reason: 'no_result' } })
22
+ })
23
+ })
@@ -0,0 +1,37 @@
1
+ import type { ThreadCompletion } from '@zooid/core'
2
+
3
+ /** ACP's stable prompt termination values (kept local to avoid an SDK runtime dep). */
4
+ export type StopReason = 'end_turn' | 'max_tokens' | 'max_turn_requests' | 'refusal' | 'cancelled'
5
+
6
+ export interface CompletionInputs {
7
+ agent: string
8
+ threadId: string
9
+ stopReason?: StopReason
10
+ error?: unknown
11
+ summary?: string
12
+ prose?: string
13
+ outstanding: number
14
+ awaitingHuman: number
15
+ }
16
+ export type CompletionDecision =
17
+ | { decision: 'stay_open'; reason: 'outstanding_handoff' | 'awaiting_human' }
18
+ | { decision: 'finish'; completion: ThreadCompletion }
19
+
20
+ export function evaluateCompletion(input: CompletionInputs): CompletionDecision {
21
+ const prose = input.prose?.trim()
22
+ const output = prose ? { type: 'message' as const, text: prose } : undefined
23
+ const finish = (completion: Omit<ThreadCompletion, 'agent' | 'thread_id'>): CompletionDecision => ({
24
+ decision: 'finish', completion: { agent: input.agent, thread_id: input.threadId, ...completion },
25
+ })
26
+ if (input.error !== undefined)
27
+ return finish({ status: 'failed', error: input.error instanceof Error ? input.error.message : String(input.error), ...(output ? { output } : {}) })
28
+ if (input.stopReason === 'cancelled') return finish({ status: 'cancelled', ...(output ? { output } : {}) })
29
+ if (input.stopReason === 'max_tokens' || input.stopReason === 'max_turn_requests')
30
+ return finish({ status: 'partial', reason: input.stopReason, ...(output ? { output } : {}) })
31
+ if (input.stopReason === 'refusal') return finish({ status: 'failed', reason: 'refusal', ...(output ? { output } : {}) })
32
+ if (input.awaitingHuman > 0) return { decision: 'stay_open', reason: 'awaiting_human' }
33
+ if (input.outstanding > 0) return { decision: 'stay_open', reason: 'outstanding_handoff' }
34
+ if (input.summary) return finish({ status: 'complete', output: { type: 'message', text: input.summary } })
35
+ if (output) return finish({ status: 'complete', output })
36
+ return finish({ status: 'failed', reason: 'no_result', error: 'No result produced' })
37
+ }
@@ -0,0 +1,53 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { buildAssignmentContent, checkDelegable, renderCompletionPrompt } from './task-dispatch.js'
3
+ import type { AgentBinding } from './router.js'
4
+ const agents: AgentBinding[] = [
5
+ {
6
+ name: 'supervisor',
7
+ userId: '@supervisor:hs',
8
+ rooms: [{ alias: '!r:hs' }],
9
+ trigger: 'mention',
10
+ },
11
+ {
12
+ name: 'worker',
13
+ userId: '@worker:hs',
14
+ rooms: [{ alias: '!r:hs' }],
15
+ trigger: 'mention',
16
+ },
17
+ ]
18
+ describe('task dispatch', () => {
19
+ it('admits only local room agents', () => {
20
+ expect(checkDelegable('worker', '!r:hs', agents)).toEqual({ ok: true })
21
+ expect(checkDelegable('ghost', '!r:hs', agents)).toMatchObject({
22
+ ok: false,
23
+ reason: expect.stringContaining('unknown_agent'),
24
+ })
25
+ })
26
+ it('makes a visible, unthreaded signed root and renders a return', () => {
27
+ const content = buildAssignmentContent({
28
+ assigneeUserId: '@worker:hs',
29
+ prompt: 'audit',
30
+ start: {
31
+ version: 1,
32
+ assignee: 'worker',
33
+ attempt_id: 'a1',
34
+ parent: { agent: 'supervisor', thread_root: '$p', session_key: '$p' },
35
+ notify: 'caller',
36
+ },
37
+ })
38
+ expect(content).toMatchObject({
39
+ msgtype: 'm.notice',
40
+ body: '@worker:hs audit',
41
+ 'm.mentions': { user_ids: ['@worker:hs'] },
42
+ })
43
+ expect(content['m.relates_to']).toBeUndefined()
44
+ expect(
45
+ renderCompletionPrompt({
46
+ agent: 'worker',
47
+ thread_id: '$task',
48
+ status: 'complete',
49
+ output: { type: 'message', text: 'done' },
50
+ }),
51
+ ).toContain('done')
52
+ })
53
+ })
@@ -0,0 +1,68 @@
1
+ import { THREAD_START_FIELD, type ThreadCompletion, type ThreadStartContent } from '@zooid/core'
2
+ import type { AgentBinding } from './router.js'
3
+ export type Admission = { ok: true } | { ok: false; reason: string }
4
+ export function checkDelegable(
5
+ agentName: string,
6
+ roomId: string,
7
+ bindings: AgentBinding[],
8
+ ): Admission {
9
+ const target = bindings.find((b) => b.name === agentName)
10
+ if (!target)
11
+ return {
12
+ ok: false,
13
+ reason: `unknown_agent: no agent named "${agentName}" is configured here`,
14
+ }
15
+ if (!target.rooms.some((r) => r.alias === roomId))
16
+ return {
17
+ ok: false,
18
+ reason: `not_in_room: "${agentName}" is not a member of this room`,
19
+ }
20
+ return { ok: true }
21
+ }
22
+ export function buildAssignmentContent(input: {
23
+ assigneeUserId: string
24
+ prompt: string
25
+ start: ThreadStartContent
26
+ }): { msgtype: string; body: string; [key: string]: unknown } {
27
+ const escaped = input.prompt.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
28
+ const body = `${input.assigneeUserId} ${input.prompt}`.trim()
29
+ return {
30
+ msgtype: 'm.notice',
31
+ body,
32
+ format: 'org.matrix.custom.html',
33
+ formatted_body: `<a href="https://matrix.to/#/${encodeURIComponent(input.assigneeUserId)}">${input.assigneeUserId}</a> ${escaped}`,
34
+ 'm.mentions': { user_ids: [input.assigneeUserId] },
35
+ [THREAD_START_FIELD]: input.start,
36
+ }
37
+ }
38
+ export function renderCompletionPrompt(c: ThreadCompletion) {
39
+ return [
40
+ `[task result] ${c.agent} — status: ${c.status} (thread ${c.thread_id})`,
41
+ ...(c.reason ? [`reason: ${c.reason}`] : []),
42
+ ...(c.error ? [`error: ${c.error}`] : []),
43
+ ...(c.output?.text ? ['', c.output.text] : []),
44
+ ].join('\n')
45
+ }
46
+ export function renderInvocationReturn(c: ThreadCompletion) {
47
+ return [
48
+ `[handoff result] ${c.agent} — status: ${c.status}`,
49
+ ...(c.reason ? [`reason: ${c.reason}`] : []),
50
+ ...(c.error ? [`error: ${c.error}`] : []),
51
+ ...(c.output?.text ? ['', c.output.text] : []),
52
+ ].join('\n')
53
+ }
54
+ export function renderDelivery(notify: 'caller' | 'none'): string {
55
+ return notify === 'caller'
56
+ ? 'Each result returns to you as a new turn when that task completes. End your turn now — do not read the task thread to wait for it.'
57
+ : 'No result returns to you. The task thread is the result surface; thread_id is for later reference, not something to wait on.'
58
+ }
59
+ export function renderAssigneeEnvelope(input: { parentAgent: string; prompt: string }): string {
60
+ return [
61
+ `[task] from ${input.parentAgent} — you are the assignee of this thread.`,
62
+ 'Call zooid_complete_task with a self-contained summary when you are done;',
63
+ 'ending your turn without one publishes your last message as the result.',
64
+ 'Sibling task threads are refused here — @mention an agent in this thread to hand off.',
65
+ '',
66
+ input.prompt,
67
+ ].join('\n')
68
+ }
@@ -0,0 +1,27 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { renderAssigneeEnvelope, renderDelivery } from './task-dispatch.js'
3
+
4
+ describe('renderAssigneeEnvelope', () => {
5
+ it('names the caller, the owed result, and the depth cap', () => {
6
+ const out = renderAssigneeEnvelope({
7
+ parentAgent: 'zooid-assistant',
8
+ prompt: 'Write a one-sentence bug report.',
9
+ })
10
+ expect(out).toMatch(/^\[task\] from zooid-assistant/)
11
+ expect(out).toMatch(/zooid_complete_task/)
12
+ expect(out).toMatch(/ending your turn without one/i)
13
+ expect(out).toMatch(/@mention/)
14
+ expect(out.endsWith('Write a one-sentence bug report.')).toBe(true)
15
+ })
16
+ })
17
+
18
+ describe('renderDelivery', () => {
19
+ it('tells a notify:caller supervisor to stop and wait', () => {
20
+ expect(renderDelivery('caller')).toMatch(/new turn/)
21
+ expect(renderDelivery('caller')).toMatch(/do not read the task thread/i)
22
+ })
23
+ it('tells a notify:none supervisor the thread is the result surface', () => {
24
+ expect(renderDelivery('none')).toMatch(/no result returns/i)
25
+ expect(renderDelivery('none')).not.toMatch(/new turn/)
26
+ })
27
+ })
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { MAX_OPEN_TASKS_PER_ROOM, TaskRegistry } from './task-registry.js'
3
+
4
+ const parent = {
5
+ agent: 'supervisor',
6
+ threadRoot: '$parent',
7
+ sessionKey: '$parent',
8
+ generation: 0,
9
+ }
10
+ const reserve = (r: TaskRegistry, roomId = '!room:hs') =>
11
+ r.reserve({ roomId, assignee: 'worker', notify: 'caller', parent })
12
+
13
+ describe('TaskRegistry', () => {
14
+ it('enforces the open-task cap independently per room and releases once', () => {
15
+ const r = new TaskRegistry({
16
+ newId: (() => {
17
+ let i = 0
18
+ return () => `t${++i}`
19
+ })(),
20
+ })
21
+ for (let i = 0; i < MAX_OPEN_TASKS_PER_ROOM; i++) expect(reserve(r)).toBeDefined()
22
+ expect(reserve(r)).toBeUndefined()
23
+ const task = r.taskForRoot('$root')
24
+ expect(task).toBeUndefined()
25
+ r.activate('t1', '$root')
26
+ expect(r.close('t1')).toBe(true)
27
+ expect(r.close('t1')).toBe(false)
28
+ expect(reserve(r)).toBeDefined()
29
+ expect(reserve(r, '!other:hs')).toBeDefined()
30
+ })
31
+ it('adopts only issued attempts, retains closed roots, and protects uncertain sends', () => {
32
+ const r = new TaskRegistry({ maxOpenPerRoom: 1, newId: () => 'attempt' })
33
+ const task = reserve(r)!
34
+ r.markUncertain(task.taskId)
35
+ expect(reserve(r)).toBeUndefined()
36
+ expect(r.adopt('forged', '$bad')).toBeUndefined()
37
+ expect(r.adopt(task.attemptId, '$root')?.phase).toBe('open')
38
+ expect(r.openTaskFor('worker', '$root')?.taskId).toBe(task.taskId)
39
+ expect(r.recordSummary(task.taskId, 'first')).toBe('recorded')
40
+ expect(r.recordSummary(task.taskId, 'second')).toBe('already_recorded')
41
+ r.close(task.taskId)
42
+ expect(r.taskForRoot('$root')?.phase).toBe('closed')
43
+ })
44
+ it('bumps session generations after a reset', () => {
45
+ const r = new TaskRegistry()
46
+ expect(r.generationOf('a', '$s')).toBe(0)
47
+ r.bumpGeneration('a', '$s')
48
+ expect(r.generationOf('a', '$s')).toBe(1)
49
+ })
50
+ })
@@ -0,0 +1,152 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ export const MAX_OPEN_TASKS_PER_ROOM = 5
3
+ export type TaskPhase = 'reserved' | 'uncertain' | 'open' | 'closed'
4
+ export interface TaskRecord {
5
+ taskId: string
6
+ attemptId: string
7
+ roomId: string
8
+ assignee: string
9
+ notify: 'caller' | 'none'
10
+ parent: {
11
+ agent: string
12
+ threadRoot: string
13
+ sessionKey: string
14
+ generation: number
15
+ }
16
+ phase: TaskPhase
17
+ threadRoot?: string
18
+ summary?: string
19
+ runId?: string
20
+ closedAt?: string
21
+ }
22
+ export interface PersistedTask extends Required<Pick<TaskRecord, 'taskId' | 'attemptId' | 'roomId' | 'assignee' | 'notify' | 'parent' | 'phase'>> {
23
+ threadRoot?: string
24
+ summary?: string
25
+ runId: string
26
+ closedAt?: string
27
+ }
28
+ export interface TaskJournal { load(): PersistedTask[]; save(tasks: PersistedTask[]): void }
29
+ export class TaskRegistry {
30
+ private readonly tasks = new Map<string, TaskRecord>()
31
+ private readonly byRoot = new Map<string, string>()
32
+ private readonly generations = new Map<string, number>()
33
+ private readonly runIdValue: string
34
+ constructor(
35
+ private readonly opts: {
36
+ maxOpenPerRoom?: number
37
+ newId?: () => string
38
+ journal?: TaskJournal
39
+ runId?: string
40
+ maxClosedRecords?: number
41
+ } = {},
42
+ ) {
43
+ this.runIdValue = this.opts.runId ?? randomUUID()
44
+ }
45
+ private get max() {
46
+ return this.opts.maxOpenPerRoom ?? MAX_OPEN_TASKS_PER_ROOM
47
+ }
48
+ private get runId() { return this.runIdValue }
49
+ private save() {
50
+ if (!this.opts.journal) return
51
+ const rows = [...this.tasks.values()].map((r) => ({ ...r, runId: r.runId ?? this.runId }) as PersistedTask)
52
+ const max = this.opts.maxClosedRecords ?? 500
53
+ const open = rows.filter((r) => r.phase !== 'closed')
54
+ const closed = rows.filter((r) => r.phase === 'closed').sort((a, b) => (b.closedAt ?? '').localeCompare(a.closedAt ?? '')).slice(0, max)
55
+ this.opts.journal.save([...open, ...closed])
56
+ }
57
+ openCount(roomId: string) {
58
+ return [...this.tasks.values()].filter((t) => t.roomId === roomId && t.phase !== 'closed')
59
+ .length
60
+ }
61
+ reserve(
62
+ input: Omit<TaskRecord, 'taskId' | 'attemptId' | 'phase' | 'threadRoot' | 'summary'>,
63
+ ): TaskRecord | undefined {
64
+ if (this.openCount(input.roomId) >= this.max) return
65
+ const id = this.opts.newId?.() ?? randomUUID()
66
+ const rec: TaskRecord = {
67
+ taskId: id,
68
+ attemptId: id,
69
+ phase: 'reserved',
70
+ runId: this.runId,
71
+ ...input,
72
+ }
73
+ this.tasks.set(id, rec)
74
+ this.save()
75
+ return rec
76
+ }
77
+ activate(taskId: string, threadRoot: string) {
78
+ const r = this.tasks.get(taskId)
79
+ if (!r) return
80
+ r.phase = 'open'
81
+ r.threadRoot = threadRoot
82
+ this.byRoot.set(threadRoot, taskId)
83
+ this.save()
84
+ }
85
+ abandon(taskId: string) {
86
+ this.tasks.delete(taskId)
87
+ this.save()
88
+ }
89
+ markUncertain(taskId: string) {
90
+ const r = this.tasks.get(taskId)
91
+ if (r?.phase === 'reserved') r.phase = 'uncertain'
92
+ this.save()
93
+ }
94
+ adopt(attemptId: string, threadRoot: string) {
95
+ const r = this.tasks.get(attemptId)
96
+ if (!r) return
97
+ if (r.phase === 'closed' || (r.threadRoot && r.threadRoot !== threadRoot)) return r
98
+ this.activate(r.taskId, threadRoot)
99
+ return r
100
+ }
101
+ taskForRoot(threadRoot: string) {
102
+ const id = this.byRoot.get(threadRoot)
103
+ return id ? this.tasks.get(id) : undefined
104
+ }
105
+ openTaskFor(agent: string, root: string) {
106
+ const r = this.taskForRoot(root)
107
+ return r?.phase === 'open' && r.assignee === agent ? r : undefined
108
+ }
109
+ recordSummary(id: string, summary: string) {
110
+ const r = this.tasks.get(id)
111
+ if (!r || r.summary !== undefined) return 'already_recorded' as const
112
+ r.summary = summary
113
+ this.save()
114
+ return 'recorded' as const
115
+ }
116
+ clearSummary(id: string) {
117
+ const r = this.tasks.get(id)
118
+ if (r) r.summary = undefined
119
+ this.save()
120
+ }
121
+ close(id: string) {
122
+ const r = this.tasks.get(id)
123
+ if (!r || r.phase === 'closed') return false
124
+ r.phase = 'closed'
125
+ r.closedAt = new Date().toISOString()
126
+ this.save()
127
+ return true
128
+ }
129
+ /** Reconcile records from a prior daemon run and retain closed roots for trust checks. */
130
+ restore(): TaskRecord[] {
131
+ const rows = this.opts.journal?.load() ?? []
132
+ const interrupted: TaskRecord[] = []
133
+ for (const row of rows) {
134
+ const rec: TaskRecord = { ...row }
135
+ if (rec.phase !== 'closed' && rec.runId !== this.runId) {
136
+ rec.phase = 'closed'; rec.closedAt = new Date().toISOString()
137
+ interrupted.push(rec)
138
+ }
139
+ this.tasks.set(rec.taskId, rec)
140
+ if (rec.threadRoot) this.byRoot.set(rec.threadRoot, rec.taskId)
141
+ }
142
+ this.save()
143
+ return interrupted
144
+ }
145
+ generationOf(agent: string, session: string) {
146
+ return this.generations.get(`${agent}::${session}`) ?? 0
147
+ }
148
+ bumpGeneration(agent: string, session: string) {
149
+ const k = `${agent}::${session}`
150
+ this.generations.set(k, this.generationOf(agent, session) + 1)
151
+ }
152
+ }