@zooid/transport-matrix 0.12.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/dist/index.d.ts +164 -5
- package/dist/index.js +946 -101
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/context-provider.test.ts +178 -3
- package/src/context-provider.ts +62 -5
- package/src/event-encoders.test.ts +69 -1
- package/src/event-encoders.ts +41 -1
- package/src/index.ts +23 -3
- package/src/invocation-registry.test.ts +22 -0
- package/src/invocation-registry.ts +32 -0
- package/src/matrix-client.ts +58 -34
- package/src/router.test.ts +129 -10
- package/src/router.ts +78 -2
- package/src/task-completion.test.ts +23 -0
- package/src/task-completion.ts +37 -0
- package/src/task-dispatch.test.ts +53 -0
- package/src/task-dispatch.ts +68 -0
- package/src/task-envelope.test.ts +27 -0
- package/src/task-registry.test.ts +50 -0
- package/src/task-registry.ts +152 -0
- package/src/transport.test.ts +520 -111
- package/src/transport.ts +760 -116
package/src/matrix-client.ts
CHANGED
|
@@ -11,6 +11,7 @@ export interface SendMessageInput {
|
|
|
11
11
|
asUserId: string
|
|
12
12
|
content: { msgtype: string; body: string; [k: string]: unknown }
|
|
13
13
|
threadRoot?: string
|
|
14
|
+
txnId?: string
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
export interface SendCustomEventInput {
|
|
@@ -18,6 +19,7 @@ export interface SendCustomEventInput {
|
|
|
18
19
|
asUserId: string
|
|
19
20
|
eventType: string
|
|
20
21
|
content: Record<string, unknown>
|
|
22
|
+
txnId?: string
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
export interface SetTypingInput {
|
|
@@ -51,7 +53,10 @@ export class MatrixClient {
|
|
|
51
53
|
const r = await this.fetch(`${this.homeserver}/_matrix/client/v3/register`, {
|
|
52
54
|
method: 'POST',
|
|
53
55
|
headers: { Authorization: `Bearer ${this.asToken}` },
|
|
54
|
-
body: JSON.stringify({
|
|
56
|
+
body: JSON.stringify({
|
|
57
|
+
type: 'm.login.application_service',
|
|
58
|
+
username: localpart,
|
|
59
|
+
}),
|
|
55
60
|
})
|
|
56
61
|
if (r.status === 200) return (await r.json()) as { user_id: string; device_id: string }
|
|
57
62
|
if (r.status === 400) {
|
|
@@ -143,10 +148,7 @@ export class MatrixClient {
|
|
|
143
148
|
return j.room_id
|
|
144
149
|
}
|
|
145
150
|
|
|
146
|
-
async createRoomRaw(opts: {
|
|
147
|
-
asUserId: string
|
|
148
|
-
body: Record<string, unknown>
|
|
149
|
-
}): Promise<string> {
|
|
151
|
+
async createRoomRaw(opts: { asUserId: string; body: Record<string, unknown> }): Promise<string> {
|
|
150
152
|
const url = `${this.homeserver}/_matrix/client/v3/createRoom?user_id=${encodeURIComponent(opts.asUserId)}`
|
|
151
153
|
const r = await this.fetch(url, {
|
|
152
154
|
method: 'POST',
|
|
@@ -190,11 +192,7 @@ export class MatrixClient {
|
|
|
190
192
|
* already invited" responses idempotently so bootstrap can run on a
|
|
191
193
|
* fresh AND a populated homeserver without branching.
|
|
192
194
|
*/
|
|
193
|
-
async invite(opts: {
|
|
194
|
-
roomId: string
|
|
195
|
-
asUserId: string
|
|
196
|
-
targetUserId: string
|
|
197
|
-
}): Promise<void> {
|
|
195
|
+
async invite(opts: { roomId: string; asUserId: string; targetUserId: string }): Promise<void> {
|
|
198
196
|
const url =
|
|
199
197
|
`${this.homeserver}/_matrix/client/v3/rooms/${encodeURIComponent(opts.roomId)}/invite` +
|
|
200
198
|
`?user_id=${encodeURIComponent(opts.asUserId)}`
|
|
@@ -229,11 +227,7 @@ export class MatrixClient {
|
|
|
229
227
|
throw new Error(`invite(${opts.targetUserId}) failed: ${r.status}`)
|
|
230
228
|
}
|
|
231
229
|
|
|
232
|
-
async leaveRoom(
|
|
233
|
-
roomId: string,
|
|
234
|
-
asUserId: string,
|
|
235
|
-
opts?: { reason?: string },
|
|
236
|
-
): Promise<void> {
|
|
230
|
+
async leaveRoom(roomId: string, asUserId: string, opts?: { reason?: string }): Promise<void> {
|
|
237
231
|
const url =
|
|
238
232
|
`${this.homeserver}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/leave` +
|
|
239
233
|
`?user_id=${encodeURIComponent(asUserId)}`
|
|
@@ -263,13 +257,16 @@ export class MatrixClient {
|
|
|
263
257
|
async sendMessage(input: SendMessageInput): Promise<{ event_id: string }> {
|
|
264
258
|
const content: Record<string, unknown> = { ...input.content }
|
|
265
259
|
if (input.threadRoot) {
|
|
266
|
-
content['m.relates_to'] = {
|
|
260
|
+
content['m.relates_to'] = {
|
|
261
|
+
rel_type: 'm.thread',
|
|
262
|
+
event_id: input.threadRoot,
|
|
263
|
+
}
|
|
267
264
|
}
|
|
268
|
-
return this.sendEvent(input.roomId, input.asUserId, 'm.room.message', content)
|
|
265
|
+
return this.sendEvent(input.roomId, input.asUserId, 'm.room.message', content, input.txnId)
|
|
269
266
|
}
|
|
270
267
|
|
|
271
268
|
async sendCustomEvent(input: SendCustomEventInput): Promise<{ event_id: string }> {
|
|
272
|
-
return this.sendEvent(input.roomId, input.asUserId, input.eventType, input.content)
|
|
269
|
+
return this.sendEvent(input.roomId, input.asUserId, input.eventType, input.content, input.txnId)
|
|
273
270
|
}
|
|
274
271
|
|
|
275
272
|
async setTyping(input: SetTypingInput): Promise<void> {
|
|
@@ -356,9 +353,14 @@ export class MatrixClient {
|
|
|
356
353
|
user_id: opts.asUserId,
|
|
357
354
|
})
|
|
358
355
|
if (opts.from) params.set('from', opts.from)
|
|
356
|
+
// The 3-segment relations endpoint (rel_type + event_type) scopes both the
|
|
357
|
+
// returned chunk AND the next_batch cursor to m.room.message server-side —
|
|
358
|
+
// without it we'd paginate over every m.thread relation (edits, reactions,
|
|
359
|
+
// redactions) and toMessage()'s client-side filtering would desync from
|
|
360
|
+
// has_more/next_before (zooid-ai/zooid#21).
|
|
359
361
|
const url =
|
|
360
362
|
`${this.homeserver}/_matrix/client/v1/rooms/${encodeURIComponent(opts.roomId)}` +
|
|
361
|
-
`/relations/${encodeURIComponent(opts.rootEventId)}/m.thread?${params.toString()}`
|
|
363
|
+
`/relations/${encodeURIComponent(opts.rootEventId)}/m.thread/m.room.message?${params.toString()}`
|
|
362
364
|
const r = await this.fetch(url, {
|
|
363
365
|
method: 'GET',
|
|
364
366
|
headers: { Authorization: `Bearer ${this.asToken}` },
|
|
@@ -409,7 +411,10 @@ export class MatrixClient {
|
|
|
409
411
|
headers: { Authorization: `Bearer ${this.asToken}` },
|
|
410
412
|
})
|
|
411
413
|
if (!r.ok) throw new Error(`fetchRoomMessages(${opts.roomId}) failed: ${r.status}`)
|
|
412
|
-
return (await r.json()) as {
|
|
414
|
+
return (await r.json()) as {
|
|
415
|
+
chunk: Array<Record<string, unknown>>
|
|
416
|
+
end?: string
|
|
417
|
+
}
|
|
413
418
|
}
|
|
414
419
|
|
|
415
420
|
async getJoinedMembers(
|
|
@@ -424,19 +429,24 @@ export class MatrixClient {
|
|
|
424
429
|
headers: { Authorization: `Bearer ${this.asToken}` },
|
|
425
430
|
})
|
|
426
431
|
if (!r.ok) throw new Error(`getJoinedMembers(${roomId}) failed: ${r.status}`)
|
|
427
|
-
return (await r.json()) as {
|
|
432
|
+
return (await r.json()) as {
|
|
433
|
+
joined: Record<string, { display_name?: string }>
|
|
434
|
+
}
|
|
428
435
|
}
|
|
429
436
|
|
|
430
|
-
async sync(opts: {
|
|
431
|
-
asUserId: string
|
|
432
|
-
since?: string | null
|
|
433
|
-
timeoutMs?: number
|
|
434
|
-
}): Promise<{
|
|
437
|
+
async sync(opts: { asUserId: string; since?: string | null; timeoutMs?: number }): Promise<{
|
|
435
438
|
next_batch: string
|
|
436
439
|
rooms: {
|
|
437
|
-
join: Record<
|
|
438
|
-
|
|
439
|
-
|
|
440
|
+
join: Record<
|
|
441
|
+
string,
|
|
442
|
+
{
|
|
443
|
+
timeline: {
|
|
444
|
+
events: Record<string, unknown>[]
|
|
445
|
+
prev_batch?: string
|
|
446
|
+
limited?: boolean
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
>
|
|
440
450
|
}
|
|
441
451
|
}> {
|
|
442
452
|
const params = new URLSearchParams({
|
|
@@ -452,9 +462,16 @@ export class MatrixClient {
|
|
|
452
462
|
return r.json() as Promise<{
|
|
453
463
|
next_batch: string
|
|
454
464
|
rooms: {
|
|
455
|
-
join: Record<
|
|
456
|
-
|
|
457
|
-
|
|
465
|
+
join: Record<
|
|
466
|
+
string,
|
|
467
|
+
{
|
|
468
|
+
timeline: {
|
|
469
|
+
events: Record<string, unknown>[]
|
|
470
|
+
prev_batch?: string
|
|
471
|
+
limited?: boolean
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
>
|
|
458
475
|
}
|
|
459
476
|
}>
|
|
460
477
|
}
|
|
@@ -478,8 +495,9 @@ export class MatrixClient {
|
|
|
478
495
|
asUserId: string,
|
|
479
496
|
eventType: string,
|
|
480
497
|
content: Record<string, unknown>,
|
|
498
|
+
txnId?: string,
|
|
481
499
|
): Promise<{ event_id: string }> {
|
|
482
|
-
const txn = randomUUID()
|
|
500
|
+
const txn = txnId ?? randomUUID()
|
|
483
501
|
const url =
|
|
484
502
|
`${this.homeserver}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}` +
|
|
485
503
|
`/send/${eventType}/${txn}?user_id=${encodeURIComponent(asUserId)}`
|
|
@@ -488,7 +506,13 @@ export class MatrixClient {
|
|
|
488
506
|
headers: { Authorization: `Bearer ${this.asToken}` },
|
|
489
507
|
body: JSON.stringify(content),
|
|
490
508
|
})
|
|
491
|
-
if (!r.ok)
|
|
509
|
+
if (!r.ok) {
|
|
510
|
+
const err = new Error(`sendEvent(${eventType}) failed: ${r.status}`) as Error & {
|
|
511
|
+
status?: number
|
|
512
|
+
}
|
|
513
|
+
err.status = r.status
|
|
514
|
+
throw err
|
|
515
|
+
}
|
|
492
516
|
return (await r.json()) as { event_id: string }
|
|
493
517
|
}
|
|
494
518
|
}
|
package/src/router.test.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
route,
|
|
4
|
+
isMediaMsgtype,
|
|
5
|
+
wouldCycleCallers,
|
|
6
|
+
type AgentBinding,
|
|
7
|
+
type ThreadState,
|
|
8
|
+
} from './router.js'
|
|
3
9
|
|
|
4
10
|
const agents: AgentBinding[] = [
|
|
5
11
|
{
|
|
@@ -17,7 +23,12 @@ const agents: AgentBinding[] = [
|
|
|
17
23
|
]
|
|
18
24
|
|
|
19
25
|
function msg(
|
|
20
|
-
overrides: Partial<{
|
|
26
|
+
overrides: Partial<{
|
|
27
|
+
room: string
|
|
28
|
+
sender: string
|
|
29
|
+
body: string
|
|
30
|
+
mentions: string[]
|
|
31
|
+
}> = {},
|
|
21
32
|
) {
|
|
22
33
|
return {
|
|
23
34
|
type: 'm.room.message',
|
|
@@ -55,7 +66,10 @@ describe('route', () => {
|
|
|
55
66
|
|
|
56
67
|
it('skips events whose sender is the matched agent itself', () => {
|
|
57
68
|
const matches = route(
|
|
58
|
-
msg({
|
|
69
|
+
msg({
|
|
70
|
+
sender: '@architect:example.com',
|
|
71
|
+
mentions: ['@architect:example.com'],
|
|
72
|
+
}),
|
|
59
73
|
agents,
|
|
60
74
|
)
|
|
61
75
|
expect(matches).toEqual([])
|
|
@@ -71,10 +85,7 @@ describe('route', () => {
|
|
|
71
85
|
trigger: 'mention',
|
|
72
86
|
},
|
|
73
87
|
]
|
|
74
|
-
const matches = route(
|
|
75
|
-
msg({ mentions: ['@architect:example.com', '@qa:example.com'] }),
|
|
76
|
-
both,
|
|
77
|
-
)
|
|
88
|
+
const matches = route(msg({ mentions: ['@architect:example.com', '@qa:example.com'] }), both)
|
|
78
89
|
expect(matches.map((m) => m.name).sort()).toEqual(['architect', 'qa'])
|
|
79
90
|
})
|
|
80
91
|
|
|
@@ -89,6 +100,86 @@ describe('route', () => {
|
|
|
89
100
|
})
|
|
90
101
|
})
|
|
91
102
|
|
|
103
|
+
describe('directed task routing', () => {
|
|
104
|
+
const agents: AgentBinding[] = [
|
|
105
|
+
{
|
|
106
|
+
name: 'supervisor',
|
|
107
|
+
userId: '@supervisor:hs',
|
|
108
|
+
rooms: [{ alias: '!r:hs' }],
|
|
109
|
+
trigger: 'mention',
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
name: 'worker',
|
|
113
|
+
userId: '@worker:hs',
|
|
114
|
+
rooms: [{ alias: '!r:hs' }],
|
|
115
|
+
trigger: 'mention',
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
name: 'eager',
|
|
119
|
+
userId: '@eager:hs',
|
|
120
|
+
rooms: [{ alias: '!r:hs' }],
|
|
121
|
+
trigger: 'any',
|
|
122
|
+
},
|
|
123
|
+
]
|
|
124
|
+
const root = {
|
|
125
|
+
type: 'm.room.message',
|
|
126
|
+
room_id: '!r:hs',
|
|
127
|
+
sender: '@supervisor:hs',
|
|
128
|
+
content: {
|
|
129
|
+
msgtype: 'm.notice',
|
|
130
|
+
body: '@worker:hs task',
|
|
131
|
+
'm.mentions': { user_ids: ['@worker:hs'] },
|
|
132
|
+
},
|
|
133
|
+
}
|
|
134
|
+
it('routes a task root solely to its assignee, including self assignment', () => {
|
|
135
|
+
expect(
|
|
136
|
+
route(root, agents, new Map(), { assignee: 'worker', isRoot: true }).map((x) => x.name),
|
|
137
|
+
).toEqual(['worker'])
|
|
138
|
+
expect(
|
|
139
|
+
route(root, agents, new Map(), {
|
|
140
|
+
assignee: 'supervisor',
|
|
141
|
+
isRoot: true,
|
|
142
|
+
}).map((x) => x.name),
|
|
143
|
+
).toEqual(['supervisor'])
|
|
144
|
+
})
|
|
145
|
+
it('keeps trigger:any out of a task thread while allowing human steering and explicit mentions', () => {
|
|
146
|
+
const state = new Map([
|
|
147
|
+
[
|
|
148
|
+
'$task',
|
|
149
|
+
{
|
|
150
|
+
participants: ['worker'],
|
|
151
|
+
rootMentions: ['worker'],
|
|
152
|
+
callers: {},
|
|
153
|
+
handoffs: {},
|
|
154
|
+
},
|
|
155
|
+
],
|
|
156
|
+
])
|
|
157
|
+
const human = {
|
|
158
|
+
...root,
|
|
159
|
+
sender: '@alice:hs',
|
|
160
|
+
content: {
|
|
161
|
+
msgtype: 'm.text',
|
|
162
|
+
body: 'continue',
|
|
163
|
+
'm.relates_to': { rel_type: 'm.thread', event_id: '$task' },
|
|
164
|
+
},
|
|
165
|
+
}
|
|
166
|
+
expect(
|
|
167
|
+
route(human, agents, state, { assignee: 'worker', isRoot: false }).map((x) => x.name),
|
|
168
|
+
).toEqual(['worker'])
|
|
169
|
+
const mention = {
|
|
170
|
+
...human,
|
|
171
|
+
sender: '@worker:hs',
|
|
172
|
+
content: {
|
|
173
|
+
...human.content,
|
|
174
|
+
'm.mentions': { user_ids: ['@supervisor:hs'] },
|
|
175
|
+
},
|
|
176
|
+
}
|
|
177
|
+
expect(
|
|
178
|
+
route(mention, agents, state, { assignee: 'worker', isRoot: false }).map((x) => x.name),
|
|
179
|
+
).toEqual(['supervisor'])
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
|
|
92
183
|
describe('media events', () => {
|
|
93
184
|
it('classifies media msgtypes', () => {
|
|
94
185
|
for (const t of ['m.image', 'm.file', 'm.video', 'm.audio']) {
|
|
@@ -103,7 +194,11 @@ describe('media events', () => {
|
|
|
103
194
|
const monitorRoom = msg({ room: '!alerts:example.com', body: 'dog.jpg' })
|
|
104
195
|
const mediaEvent = {
|
|
105
196
|
...monitorRoom,
|
|
106
|
-
content: {
|
|
197
|
+
content: {
|
|
198
|
+
msgtype: 'm.image',
|
|
199
|
+
body: 'dog.jpg',
|
|
200
|
+
url: 'mxc://localhost/abc',
|
|
201
|
+
},
|
|
107
202
|
}
|
|
108
203
|
const matches = route(mediaEvent, agents)
|
|
109
204
|
expect(matches).toEqual([])
|
|
@@ -169,7 +264,10 @@ describe('directional thread continuation (agent-to-agent handoffs)', () => {
|
|
|
169
264
|
|
|
170
265
|
it('an explicit @mention still re-engages the sub (rule 1 wins)', () => {
|
|
171
266
|
const matches = route(
|
|
172
|
-
threadMsg({
|
|
267
|
+
threadMsg({
|
|
268
|
+
sender: '@parent:example.com',
|
|
269
|
+
mentions: ['@sub:example.com'],
|
|
270
|
+
}),
|
|
173
271
|
pair,
|
|
174
272
|
states({ participants: ['parent', 'sub'], callers: { sub: 'parent' } }),
|
|
175
273
|
)
|
|
@@ -178,7 +276,10 @@ describe('directional thread continuation (agent-to-agent handoffs)', () => {
|
|
|
178
276
|
|
|
179
277
|
it('dedupes: a sub reply that also @mentions its caller triggers the caller once', () => {
|
|
180
278
|
const matches = route(
|
|
181
|
-
threadMsg({
|
|
279
|
+
threadMsg({
|
|
280
|
+
sender: '@sub:example.com',
|
|
281
|
+
mentions: ['@parent:example.com'],
|
|
282
|
+
}),
|
|
182
283
|
pair,
|
|
183
284
|
states({ participants: ['parent'], callers: { sub: 'parent' } }),
|
|
184
285
|
)
|
|
@@ -228,6 +329,24 @@ describe('directional thread continuation (agent-to-agent handoffs)', () => {
|
|
|
228
329
|
})
|
|
229
330
|
})
|
|
230
331
|
|
|
332
|
+
describe('caller graph cycle guard', () => {
|
|
333
|
+
it('rejects a reverse edge back to an existing caller', () => {
|
|
334
|
+
expect(wouldCycleCallers({ sub: 'parent' }, 'parent', 'sub')).toBe(true)
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
it('rejects a cycle through a deeper ancestor', () => {
|
|
338
|
+
expect(
|
|
339
|
+
wouldCycleCallers({ child: 'parent', grandchild: 'child' }, 'parent', 'grandchild'),
|
|
340
|
+
).toBe(true)
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
it('allows a new downward or sibling edge', () => {
|
|
344
|
+
const callers = { child: 'parent' }
|
|
345
|
+
expect(wouldCycleCallers(callers, 'grandchild', 'child')).toBe(false)
|
|
346
|
+
expect(wouldCycleCallers(callers, 'sibling', 'parent')).toBe(false)
|
|
347
|
+
})
|
|
348
|
+
})
|
|
349
|
+
|
|
231
350
|
describe('fan-out: two subs called in one message ([[ZOD071]] acceptance)', () => {
|
|
232
351
|
const mk = (name: string): AgentBinding => ({
|
|
233
352
|
name,
|
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 (
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
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
|
+
}
|