@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/transport.ts
CHANGED
|
@@ -1,26 +1,56 @@
|
|
|
1
1
|
import { Hono } from 'hono'
|
|
2
2
|
import { timingSafeEqual } from 'node:crypto'
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
AcpRegistry,
|
|
5
|
+
ApprovalCorrelator,
|
|
6
|
+
RegisteredApproval,
|
|
7
|
+
TaskActions,
|
|
8
|
+
PendingInputRegistry,
|
|
9
|
+
StartTaskResult,
|
|
10
|
+
StartTaskSpec,
|
|
11
|
+
ThreadCompletion,
|
|
12
|
+
ThreadStartContent,
|
|
13
|
+
} from '@zooid/core'
|
|
14
|
+
import { THREAD_RESULT_FIELD, THREAD_START_FIELD } from '@zooid/core'
|
|
4
15
|
import type { AgentEvent, ContentBlock } from '@zooid/acp-client'
|
|
5
16
|
import { MatrixClient } from './matrix-client.js'
|
|
6
17
|
import { BotPool } from './bot-pool.js'
|
|
7
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
route,
|
|
20
|
+
isMediaMsgtype,
|
|
21
|
+
isReturnRoute,
|
|
22
|
+
wouldCycleCallers,
|
|
23
|
+
type AgentBinding,
|
|
24
|
+
type ThreadState,
|
|
25
|
+
} from './router.js'
|
|
8
26
|
import { sessionKeyFor, composeHandoffKey } from './session-keys.js'
|
|
9
27
|
import { stripMention, extractMentions } from './mentions.js'
|
|
10
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
toToolCallBody,
|
|
30
|
+
toUpdateBody,
|
|
31
|
+
toPlanBody,
|
|
32
|
+
toAvailableCommandsBody,
|
|
33
|
+
toErrorBody,
|
|
34
|
+
toTurnEndBody,
|
|
35
|
+
} from './event-encoders.js'
|
|
11
36
|
import { classify } from '@zooid/acp-client'
|
|
12
37
|
import { toMatrixHtml } from './markdown-to-matrix-html.js'
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
type PendingMediaItem,
|
|
16
|
-
} from './pending-media.js'
|
|
17
|
-
import {
|
|
18
|
-
MediaClient,
|
|
19
|
-
MAX_INLINE_IMAGE_BYTES,
|
|
20
|
-
INLINE_IMAGE_MIMES,
|
|
21
|
-
} from './media-client.js'
|
|
38
|
+
import { PendingMediaStore, type PendingMediaItem } from './pending-media.js'
|
|
39
|
+
import { MediaClient, MAX_INLINE_IMAGE_BYTES, INLINE_IMAGE_MIMES } from './media-client.js'
|
|
22
40
|
import { writeAttachment } from './attachments.js'
|
|
23
41
|
import { SyncLoop } from './sync-loop.js'
|
|
42
|
+
import { NO_PENDING_INPUT } from '@zooid/core'
|
|
43
|
+
import { TaskRegistry, MAX_OPEN_TASKS_PER_ROOM, type TaskJournal, type TaskRecord } from './task-registry.js'
|
|
44
|
+
import { InvocationRegistry } from './invocation-registry.js'
|
|
45
|
+
import { evaluateCompletion, type StopReason } from './task-completion.js'
|
|
46
|
+
import {
|
|
47
|
+
buildAssignmentContent,
|
|
48
|
+
checkDelegable,
|
|
49
|
+
renderCompletionPrompt,
|
|
50
|
+
renderInvocationReturn,
|
|
51
|
+
renderAssigneeEnvelope,
|
|
52
|
+
renderDelivery,
|
|
53
|
+
} from './task-dispatch.js'
|
|
24
54
|
|
|
25
55
|
export interface MediaClientLike {
|
|
26
56
|
download(input: {
|
|
@@ -68,6 +98,12 @@ export interface CreateMatrixTransportOptions {
|
|
|
68
98
|
loadSince?: (agentUserId: string) => string | null
|
|
69
99
|
/** Pull mode: persist the `since` cursor after each sync poll. */
|
|
70
100
|
saveSince?: (agentUserId: string, since: string) => void
|
|
101
|
+
/** Durable lifecycle state; supplied by the daemon when it has a data directory. */
|
|
102
|
+
taskJournal?: TaskJournal
|
|
103
|
+
taskRunId?: string
|
|
104
|
+
pendingInput?: PendingInputRegistry
|
|
105
|
+
/** Deferred-return fallback window. Defaults to `RETURN_GRACE_MS`. */
|
|
106
|
+
returnGraceMs?: number
|
|
71
107
|
}
|
|
72
108
|
|
|
73
109
|
interface SessionContext {
|
|
@@ -77,6 +113,32 @@ interface SessionContext {
|
|
|
77
113
|
threadRoot: string
|
|
78
114
|
}
|
|
79
115
|
|
|
116
|
+
/** A callee's return to its caller, held until the callee's turn ends. */
|
|
117
|
+
interface PendingReturn {
|
|
118
|
+
roomId: string
|
|
119
|
+
threadRoot: string
|
|
120
|
+
/** Last message of the turn so far; prompts the caller when released. */
|
|
121
|
+
event: MatrixEvent
|
|
122
|
+
/** Every chunk the callee posted this turn, in order. */
|
|
123
|
+
texts: string[]
|
|
124
|
+
/** Callers to wake, by agent name. */
|
|
125
|
+
targets: Map<string, AgentBinding>
|
|
126
|
+
timer?: ReturnType<typeof setTimeout>
|
|
127
|
+
}
|
|
128
|
+
interface TurnInput {
|
|
129
|
+
roomId: string
|
|
130
|
+
threadRoot: string
|
|
131
|
+
sessionKey: string
|
|
132
|
+
promptText?: string
|
|
133
|
+
event?: MatrixEvent
|
|
134
|
+
/**
|
|
135
|
+
* Set only on the root turn of a task thread, for the assignee. Wraps the
|
|
136
|
+
* computed promptText with `renderAssigneeEnvelope` in runTurn — later
|
|
137
|
+
* turns in the same thread carry no envelope.
|
|
138
|
+
*/
|
|
139
|
+
taskEnvelope?: { parentAgent: string }
|
|
140
|
+
}
|
|
141
|
+
|
|
80
142
|
interface MatrixEvent {
|
|
81
143
|
type?: string
|
|
82
144
|
event_id?: string
|
|
@@ -95,6 +157,15 @@ interface MatrixEvent {
|
|
|
95
157
|
|
|
96
158
|
const STARTUP_GRACE_MS = 5_000
|
|
97
159
|
|
|
160
|
+
/**
|
|
161
|
+
* How long a deferred return waits for the sending agent's `dev.zooid.turn.end`
|
|
162
|
+
* before firing anyway. The turn.end always follows the turn's messages on the
|
|
163
|
+
* wire, so this only matters when there is no turn behind them at all — the
|
|
164
|
+
* daemon restarted mid-turn, or a human posted as the agent's user from a
|
|
165
|
+
* plain Matrix client. Without it such a return would strand forever.
|
|
166
|
+
*/
|
|
167
|
+
const RETURN_GRACE_MS = 90_000
|
|
168
|
+
|
|
98
169
|
interface MediaBlocksResult {
|
|
99
170
|
blocks: ContentBlock[]
|
|
100
171
|
pathLines: string[]
|
|
@@ -235,9 +306,19 @@ function inboundThreadRoot(evt: MatrixEvent): string | undefined {
|
|
|
235
306
|
}
|
|
236
307
|
|
|
237
308
|
export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
238
|
-
const {
|
|
309
|
+
const {
|
|
310
|
+
agents,
|
|
311
|
+
approvals,
|
|
312
|
+
client,
|
|
313
|
+
bindings,
|
|
314
|
+
hsToken,
|
|
315
|
+
adminUserId,
|
|
316
|
+
botUserId,
|
|
317
|
+
mode = 'appservice',
|
|
318
|
+
} = opts
|
|
239
319
|
const drainQuietMs = opts.drainQuietMs ?? DRAIN_QUIET_MS
|
|
240
320
|
const drainMaxMs = opts.drainMaxMs ?? DRAIN_MAX_MS
|
|
321
|
+
const returnGraceMs = opts.returnGraceMs ?? RETURN_GRACE_MS
|
|
241
322
|
const mediaClient = opts.media
|
|
242
323
|
const writeAttachmentFn = opts.writeAttachmentFn ?? writeAttachment
|
|
243
324
|
const pendingMedia = new PendingMediaStore()
|
|
@@ -260,6 +341,91 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
260
341
|
const sendQueue = new Map<string, Promise<void>>()
|
|
261
342
|
// Thread participation index: keyed by thread root event_id.
|
|
262
343
|
const threadStates = new Map<string, ThreadState>()
|
|
344
|
+
const taskRegistry = new TaskRegistry({ journal: opts.taskJournal, runId: opts.taskRunId })
|
|
345
|
+
const interruptedTasks = taskRegistry.restore()
|
|
346
|
+
const invocations = new InvocationRegistry()
|
|
347
|
+
const pendingInput = opts.pendingInput ?? NO_PENDING_INPUT
|
|
348
|
+
const bindingFor = (name: string) => bindings.find((b) => b.name === name)
|
|
349
|
+
const turnQueues = new Map<string, Promise<void>>()
|
|
350
|
+
/**
|
|
351
|
+
* Returns awaiting their sender's turn boundary, keyed `<agent>::<threadRoot>`.
|
|
352
|
+
* See `isReturnRoute`: an agent turn posts one message per buffered chunk, so
|
|
353
|
+
* a return must not fire per message or the caller wakes once per chunk (and
|
|
354
|
+
* its replies then read as the two agents re-triggering each other). We stash
|
|
355
|
+
* the callee's prose instead and hand the caller the whole turn at once.
|
|
356
|
+
*/
|
|
357
|
+
const pendingReturns = new Map<string, PendingReturn>()
|
|
358
|
+
const returnKey = (agentName: string, threadRoot: string) => `${agentName}::${threadRoot}`
|
|
359
|
+
|
|
360
|
+
function stashReturn(
|
|
361
|
+
sender: AgentBinding,
|
|
362
|
+
threadRoot: string,
|
|
363
|
+
roomId: string,
|
|
364
|
+
evt: MatrixEvent,
|
|
365
|
+
targets: AgentBinding[],
|
|
366
|
+
): void {
|
|
367
|
+
const key = returnKey(sender.name, threadRoot)
|
|
368
|
+
let pending = pendingReturns.get(key)
|
|
369
|
+
if (!pending) {
|
|
370
|
+
pending = { roomId, threadRoot, event: evt, texts: [], targets: new Map() }
|
|
371
|
+
pendingReturns.set(key, pending)
|
|
372
|
+
}
|
|
373
|
+
// Prompt the caller with the last event of the turn but the text of all of
|
|
374
|
+
// it — mid-turn chunks carry content the caller would otherwise never see.
|
|
375
|
+
pending.event = evt
|
|
376
|
+
const body = evt.content?.body?.trim()
|
|
377
|
+
if (body) pending.texts.push(body)
|
|
378
|
+
for (const t of targets) pending.targets.set(t.name, t)
|
|
379
|
+
if (pending.timer) clearTimeout(pending.timer)
|
|
380
|
+
pending.timer = setTimeout(() => releaseReturn(key), returnGraceMs)
|
|
381
|
+
pending.timer.unref?.()
|
|
382
|
+
console.log(
|
|
383
|
+
`[matrix] holding return ${sender.name} → ${[...pending.targets.keys()].join(',')} ` +
|
|
384
|
+
`until turn end (thread=${threadRoot})`,
|
|
385
|
+
)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function releaseReturn(key: string): void {
|
|
389
|
+
const pending = pendingReturns.get(key)
|
|
390
|
+
if (!pending) return
|
|
391
|
+
pendingReturns.delete(key)
|
|
392
|
+
if (pending.timer) clearTimeout(pending.timer)
|
|
393
|
+
const promptText = pending.texts.join('\n\n')
|
|
394
|
+
for (const target of pending.targets.values()) {
|
|
395
|
+
console.log(`[matrix] → ${target.name} (${target.userId}) [return]`)
|
|
396
|
+
void enqueueTurn(target, {
|
|
397
|
+
roomId: pending.roomId,
|
|
398
|
+
threadRoot: pending.threadRoot,
|
|
399
|
+
sessionKey: sessionKeyFor(
|
|
400
|
+
target.name,
|
|
401
|
+
pending.threadRoot,
|
|
402
|
+
threadStates.get(pending.threadRoot),
|
|
403
|
+
),
|
|
404
|
+
event: pending.event,
|
|
405
|
+
...(promptText ? { promptText } : {}),
|
|
406
|
+
})
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Cancel a held return because its target is being woken by this event anyway. */
|
|
411
|
+
function dropPendingReturn(threadRoot: string, agentName: string): void {
|
|
412
|
+
for (const [key, pending] of pendingReturns) {
|
|
413
|
+
if (pending.threadRoot !== threadRoot) continue
|
|
414
|
+
if (!pending.targets.delete(agentName)) continue
|
|
415
|
+
if (pending.targets.size === 0) {
|
|
416
|
+
if (pending.timer) clearTimeout(pending.timer)
|
|
417
|
+
pendingReturns.delete(key)
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function dropThreadReturns(threadRoot: string): void {
|
|
423
|
+
for (const [key, pending] of pendingReturns) {
|
|
424
|
+
if (pending.threadRoot !== threadRoot) continue
|
|
425
|
+
if (pending.timer) clearTimeout(pending.timer)
|
|
426
|
+
pendingReturns.delete(key)
|
|
427
|
+
}
|
|
428
|
+
}
|
|
263
429
|
// Drop events older than this — in push (appservice) mode Tuwunel may replay
|
|
264
430
|
// a backlog after the daemon was offline, and we don't want yesterday's
|
|
265
431
|
// "@docs hi" to fire now. In pull (client) mode the persisted `since` cursor
|
|
@@ -288,15 +454,17 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
288
454
|
text: string,
|
|
289
455
|
): { msgtype: string; body: string; [k: string]: unknown } => {
|
|
290
456
|
const content: { msgtype: string; body: string; [k: string]: unknown } = {
|
|
291
|
-
|
|
457
|
+
// m.notice, not m.text: .m.rule.suppress_notices silences the
|
|
458
|
+
// chunk-storm of agent prose server-side (ZNC025 §10) instead of every
|
|
459
|
+
// client having to filter it. dev.zooid.error carries the same tweak
|
|
460
|
+
// for the same reason.
|
|
461
|
+
msgtype: 'm.notice',
|
|
292
462
|
body: text,
|
|
293
463
|
}
|
|
294
464
|
const html = toMatrixHtml(text)
|
|
295
465
|
if (html) {
|
|
296
466
|
const escapedPlain =
|
|
297
|
-
'<p>' +
|
|
298
|
-
text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') +
|
|
299
|
-
'</p>'
|
|
467
|
+
'<p>' + text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') + '</p>'
|
|
300
468
|
const norm = (s: string) => s.replace(/\s+/g, ' ').trim()
|
|
301
469
|
if (norm(html) !== norm(escapedPlain)) {
|
|
302
470
|
content.format = 'org.matrix.custom.html'
|
|
@@ -312,21 +480,34 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
312
480
|
// same turn. The buffer is cleared synchronously (before the first await),
|
|
313
481
|
// so a chunk for the *next* message that arrives during the send starts
|
|
314
482
|
// fresh. Returns true when a message was enqueued.
|
|
483
|
+
const lastFlushed = new Map<string, string>()
|
|
484
|
+
|
|
315
485
|
const flushBuffer = (sessionId: string): boolean => {
|
|
316
486
|
const ctx = sessions.get(sessionId)
|
|
317
487
|
const text = buffers.get(sessionId) ?? ''
|
|
318
488
|
if (!ctx || text.length === 0) return false
|
|
319
489
|
buffers.set(sessionId, '')
|
|
490
|
+
// Kept for turn.end's push preview: the prose goes out as `m.notice` and
|
|
491
|
+
// is deliberately silenced server-side, so turn.end is the only event that
|
|
492
|
+
// can tell the user what the agent actually said.
|
|
493
|
+
lastFlushed.set(sessionId, text)
|
|
320
494
|
flushedCounts.set(sessionId, (flushedCounts.get(sessionId) ?? 0) + 1)
|
|
321
495
|
const content = buildTextContent(text)
|
|
496
|
+
const pendingInvocations = registerOutgoingHandoffs(sessionId, text)
|
|
322
497
|
const tail = (sendQueue.get(sessionId) ?? Promise.resolve()).then(async () => {
|
|
323
498
|
try {
|
|
324
|
-
await client.sendMessage({
|
|
499
|
+
const { event_id } = await client.sendMessage({
|
|
325
500
|
roomId: ctx.roomId,
|
|
326
501
|
asUserId: ctx.agent.userId,
|
|
327
502
|
content,
|
|
328
503
|
threadRoot: ctx.threadRoot,
|
|
329
504
|
})
|
|
505
|
+
for (const invocation of pendingInvocations)
|
|
506
|
+
invocations.attachCallEvent(
|
|
507
|
+
invocation.invocationId,
|
|
508
|
+
event_id,
|
|
509
|
+
composeHandoffKey(ctx.threadRoot, event_id),
|
|
510
|
+
)
|
|
330
511
|
} catch (err) {
|
|
331
512
|
console.warn(`[matrix:${ctx.agent.name}] sendMessage flush failed:`, err)
|
|
332
513
|
}
|
|
@@ -335,6 +516,29 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
335
516
|
return true
|
|
336
517
|
}
|
|
337
518
|
|
|
519
|
+
function registerOutgoingHandoffs(sessionId: string, text: string) {
|
|
520
|
+
const ctx = sessions.get(sessionId)
|
|
521
|
+
if (!ctx) return []
|
|
522
|
+
const task = taskRegistry.taskForRoot(ctx.threadRoot)
|
|
523
|
+
if (!task || task.phase !== 'open') return []
|
|
524
|
+
const sessionKey = sessionKeyFor(ctx.agent.name, ctx.threadRoot, threadStates.get(ctx.threadRoot))
|
|
525
|
+
const opened = []
|
|
526
|
+
for (const userId of extractMentions({ content: { body: text } })) {
|
|
527
|
+
const callee = bindings.find((binding) => binding.userId === userId)
|
|
528
|
+
if (!callee || callee.name === ctx.agent.name) continue
|
|
529
|
+
if (invocations.isOutstandingAncestor(sessionKey, callee.name)) {
|
|
530
|
+
void client.sendCustomEvent({
|
|
531
|
+
roomId: ctx.roomId, asUserId: ctx.agent.userId, eventType: 'dev.zooid.error',
|
|
532
|
+
content: { body: `⚠ [handoff_circular] Cannot hand off to ${callee.name}: it is waiting on ${ctx.agent.name}`, code: 'handoff_circular', message: `Cannot hand off to ${callee.name}: it is waiting on ${ctx.agent.name}`, transient: false, 'm.relates_to': { rel_type: 'm.thread', event_id: ctx.threadRoot } },
|
|
533
|
+
})
|
|
534
|
+
continue
|
|
535
|
+
}
|
|
536
|
+
opened.push(invocations.open({ taskId: task.taskId, callerAgent: ctx.agent.name, callerSessionKey: sessionKey, calleeAgent: callee.name }))
|
|
537
|
+
taskRegistry.clearSummary(task.taskId)
|
|
538
|
+
}
|
|
539
|
+
return opened
|
|
540
|
+
}
|
|
541
|
+
|
|
338
542
|
agents.onEvent = async (name, event: AgentEvent) => {
|
|
339
543
|
const ctx = sessions.get(event.sessionId)
|
|
340
544
|
if (!ctx) {
|
|
@@ -352,7 +556,12 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
352
556
|
}
|
|
353
557
|
|
|
354
558
|
if (event.type === 'agent_message_chunk') {
|
|
355
|
-
const block = event.content as {
|
|
559
|
+
const block = event.content as {
|
|
560
|
+
type?: string
|
|
561
|
+
text?: string
|
|
562
|
+
data?: string
|
|
563
|
+
mimeType?: string
|
|
564
|
+
}
|
|
356
565
|
if (block.type === 'text' && typeof block.text === 'string') {
|
|
357
566
|
// A change in ACP messageId marks the previous assistant message as
|
|
358
567
|
// complete. opencode streams each assistant message under its own id
|
|
@@ -365,8 +574,7 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
365
574
|
event.messageId !== undefined &&
|
|
366
575
|
prevMessageId !== undefined &&
|
|
367
576
|
event.messageId !== prevMessageId
|
|
368
|
-
if (event.messageId !== undefined)
|
|
369
|
-
bufferMessageIds.set(event.sessionId, event.messageId)
|
|
577
|
+
if (event.messageId !== undefined) bufferMessageIds.set(event.sessionId, event.messageId)
|
|
370
578
|
// flushBuffer clears the buffer synchronously, so the new message's
|
|
371
579
|
// text below starts fresh.
|
|
372
580
|
if (messageChanged) flushBuffer(event.sessionId)
|
|
@@ -390,7 +598,12 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
390
598
|
const ext = (block.mimeType.split('/')[1] ?? 'png').replace(/[^a-z0-9]/gi, '')
|
|
391
599
|
const filename = `image.${ext}`
|
|
392
600
|
void mediaClient
|
|
393
|
-
.upload({
|
|
601
|
+
.upload({
|
|
602
|
+
data: bytes,
|
|
603
|
+
contentType: block.mimeType,
|
|
604
|
+
filename,
|
|
605
|
+
asUserId: ctx.agent.userId,
|
|
606
|
+
})
|
|
394
607
|
.then(({ content_uri }) =>
|
|
395
608
|
client.sendMessage({
|
|
396
609
|
roomId: ctx.roomId,
|
|
@@ -469,7 +682,10 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
469
682
|
tool_call_id: handle.toolCallId,
|
|
470
683
|
options: handle.options,
|
|
471
684
|
}
|
|
472
|
-
content['m.relates_to'] = {
|
|
685
|
+
content['m.relates_to'] = {
|
|
686
|
+
rel_type: 'm.thread',
|
|
687
|
+
event_id: ctx.threadRoot,
|
|
688
|
+
}
|
|
473
689
|
if (handle.toolKind !== undefined) content.tool_kind = handle.toolKind
|
|
474
690
|
if (handle.toolTitle !== undefined) content.tool_title = handle.toolTitle
|
|
475
691
|
if (handle.toolInput !== undefined) content.tool_input = handle.toolInput
|
|
@@ -481,6 +697,58 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
481
697
|
})
|
|
482
698
|
})
|
|
483
699
|
|
|
700
|
+
function reportTurnFailure(agent: AgentBinding, input: TurnInput, err: unknown): void {
|
|
701
|
+
console.error(`[matrix] runTurn failed for ${agent.name}:`, err)
|
|
702
|
+
const c = classify(err)
|
|
703
|
+
const body = toErrorBody(
|
|
704
|
+
{
|
|
705
|
+
kind: 'error',
|
|
706
|
+
agentId: agent.name,
|
|
707
|
+
sessionId: null,
|
|
708
|
+
turnId: null,
|
|
709
|
+
code: c.code,
|
|
710
|
+
message: err instanceof Error ? err.message : String(err),
|
|
711
|
+
detail: err instanceof Error && err.stack ? err.stack.slice(0, 2000) : undefined,
|
|
712
|
+
transient: c.transient,
|
|
713
|
+
acp_error: c.acp_error,
|
|
714
|
+
},
|
|
715
|
+
input.threadRoot,
|
|
716
|
+
)
|
|
717
|
+
void client
|
|
718
|
+
.sendCustomEvent({
|
|
719
|
+
roomId: input.roomId,
|
|
720
|
+
asUserId: agent.userId,
|
|
721
|
+
eventType: 'dev.zooid.error',
|
|
722
|
+
content: body,
|
|
723
|
+
})
|
|
724
|
+
.catch((e) => console.warn(`[matrix:${agent.name}] dev.zooid.error send failed:`, e))
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function enqueueTurn(agent: AgentBinding, input: TurnInput): Promise<void> {
|
|
728
|
+
const key = `${agent.name}::${input.sessionKey}`
|
|
729
|
+
const chained = (turnQueues.get(key) ?? Promise.resolve())
|
|
730
|
+
.then(() => runTurn(agent, input))
|
|
731
|
+
.then(() => {
|
|
732
|
+
let st = threadStates.get(input.threadRoot)
|
|
733
|
+
if (!st) {
|
|
734
|
+
st = {
|
|
735
|
+
participants: [],
|
|
736
|
+
rootMentions: [],
|
|
737
|
+
callers: {},
|
|
738
|
+
handoffs: {},
|
|
739
|
+
}
|
|
740
|
+
threadStates.set(input.threadRoot, st)
|
|
741
|
+
}
|
|
742
|
+
if (st.participants.at(-1) !== agent.name) st.participants.push(agent.name)
|
|
743
|
+
})
|
|
744
|
+
.catch((err) => reportTurnFailure(agent, input, err))
|
|
745
|
+
turnQueues.set(key, chained)
|
|
746
|
+
void chained.finally(() => {
|
|
747
|
+
if (turnQueues.get(key) === chained) turnQueues.delete(key)
|
|
748
|
+
})
|
|
749
|
+
return chained
|
|
750
|
+
}
|
|
751
|
+
|
|
484
752
|
async function handleInboundEvent(evt: MatrixEvent): Promise<void> {
|
|
485
753
|
if (evt.event_id) {
|
|
486
754
|
if (seenEventIds.has(evt.event_id)) {
|
|
@@ -537,6 +805,9 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
537
805
|
return
|
|
538
806
|
}
|
|
539
807
|
console.log(`[matrix] inbound dev.zooid.session_reset in ${evt.room_id} thread=${threadRoot}`)
|
|
808
|
+
// /clear must not allow a pre-reset deferred return to wake an agent up
|
|
809
|
+
// later via either its old turn.end or the fallback timer.
|
|
810
|
+
dropThreadReturns(threadRoot)
|
|
540
811
|
// [[ZOD071]]: a thread's sessions are the thread-level one plus one per
|
|
541
812
|
// handoff arc — end them all. Reset events aren't m.room.message, so
|
|
542
813
|
// the self-heal rebuild above doesn't cover them; rebuild here if the
|
|
@@ -554,8 +825,11 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
554
825
|
const st = threadStates.get(threadRoot)
|
|
555
826
|
for (const a of bindings) {
|
|
556
827
|
agents.endSession(a.name, threadRoot)
|
|
828
|
+
taskRegistry.bumpGeneration(a.name, threadRoot)
|
|
557
829
|
for (const arc of st?.handoffs[a.name] ?? []) {
|
|
558
|
-
|
|
830
|
+
const key = composeHandoffKey(threadRoot, arc)
|
|
831
|
+
agents.endSession(a.name, key)
|
|
832
|
+
taskRegistry.bumpGeneration(a.name, key)
|
|
559
833
|
}
|
|
560
834
|
}
|
|
561
835
|
// NB: keep threadStates intact. Per ZOD039 § /clear, only the agent's
|
|
@@ -565,7 +839,10 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
565
839
|
return
|
|
566
840
|
}
|
|
567
841
|
if (evt.type === 'dev.zooid.interrupt') {
|
|
568
|
-
const content = (evt.content ?? {}) as {
|
|
842
|
+
const content = (evt.content ?? {}) as {
|
|
843
|
+
session_id?: string
|
|
844
|
+
reason?: string
|
|
845
|
+
}
|
|
569
846
|
// Thread-relation form (client-friendly): /interrupt in a thread sends
|
|
570
847
|
// an empty event with `m.relates_to: thread/<root>`. Cancel every
|
|
571
848
|
// session whose threadRoot matches.
|
|
@@ -590,6 +867,18 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
590
867
|
console.error(`[matrix] cancelSession(${t.agent}, ${t.sessionId}) failed:`, err)
|
|
591
868
|
})
|
|
592
869
|
}
|
|
870
|
+
// A live session will report ACP's `cancelled` stop reason and finish
|
|
871
|
+
// in its turn boundary, preserving any prose it already emitted. A
|
|
872
|
+
// restored/no-session task has no such boundary, so close it here.
|
|
873
|
+
const task = taskRegistry.taskForRoot(threadRoot)
|
|
874
|
+
if (task?.phase === 'open' && !targets.some((t) => t.agent === task.assignee)) {
|
|
875
|
+
const assignee = bindingFor(task.assignee)
|
|
876
|
+
if (assignee)
|
|
877
|
+
await finishTask(task, {
|
|
878
|
+
agent: assignee,
|
|
879
|
+
completion: { agent: assignee.name, thread_id: threadRoot, status: 'cancelled' },
|
|
880
|
+
})
|
|
881
|
+
}
|
|
593
882
|
return
|
|
594
883
|
}
|
|
595
884
|
// Legacy form: explicit session_id in content.
|
|
@@ -606,7 +895,10 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
606
895
|
(content.reason ? ` reason=${content.reason}` : ''),
|
|
607
896
|
)
|
|
608
897
|
await agents.cancelSession(ctx.agent.name, content.session_id).catch((err) => {
|
|
609
|
-
console.error(
|
|
898
|
+
console.error(
|
|
899
|
+
`[matrix] cancelSession(${ctx.agent.name}, ${content.session_id}) failed:`,
|
|
900
|
+
err,
|
|
901
|
+
)
|
|
610
902
|
})
|
|
611
903
|
return
|
|
612
904
|
}
|
|
@@ -621,16 +913,28 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
621
913
|
const decision = content.option_id
|
|
622
914
|
? { decision: content.decision, optionId: content.option_id }
|
|
623
915
|
: { decision: content.decision }
|
|
624
|
-
const ok = approvals.resolve(
|
|
625
|
-
content.session_id,
|
|
626
|
-
content.approval_id,
|
|
627
|
-
decision as never,
|
|
628
|
-
)
|
|
916
|
+
const ok = approvals.resolve(content.session_id, content.approval_id, decision as never)
|
|
629
917
|
if (!ok) console.warn(`[matrix] unknown approval ${content.approval_id}`)
|
|
630
918
|
return
|
|
631
919
|
}
|
|
632
920
|
logInbound(evt)
|
|
633
921
|
|
|
922
|
+
// The callee's turn boundary: the daemon sends this only after that turn's
|
|
923
|
+
// whole send queue has drained, so every message it produced has already
|
|
924
|
+
// arrived above. Release the return it was holding.
|
|
925
|
+
if (evt.type === 'dev.zooid.turn.end') {
|
|
926
|
+
const agentId = evt.content?.agent_id as string | undefined
|
|
927
|
+
const endedRoot = inboundThreadRoot(evt)
|
|
928
|
+
const senderAgent = bindings.find((binding) => binding.userId === evt.sender)
|
|
929
|
+
// Bind the claimed agent_id to the Matrix sender. Besides rejecting a
|
|
930
|
+
// malformed boundary, this prevents another room member from releasing
|
|
931
|
+
// a held agent return early by forging custom-event content.
|
|
932
|
+
if (agentId && endedRoot && senderAgent?.name === agentId) {
|
|
933
|
+
releaseReturn(returnKey(agentId, endedRoot))
|
|
934
|
+
}
|
|
935
|
+
return
|
|
936
|
+
}
|
|
937
|
+
|
|
634
938
|
// Capture media events in the pending store; never route them to agents.
|
|
635
939
|
if (
|
|
636
940
|
evt.type === 'm.room.message' &&
|
|
@@ -676,7 +980,46 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
676
980
|
console.warn(`[matrix] failed to rebuild threadState for ${inboundRel}:`, err)
|
|
677
981
|
}
|
|
678
982
|
}
|
|
679
|
-
const
|
|
983
|
+
const startField = evt.content?.[THREAD_START_FIELD] as ThreadStartContent | undefined
|
|
984
|
+
if (startField?.attempt_id && !inboundRel && evt.event_id)
|
|
985
|
+
taskRegistry.adopt(startField.attempt_id, evt.event_id)
|
|
986
|
+
if (evt.content?.[THREAD_RESULT_FIELD] !== undefined) return
|
|
987
|
+
const taskRec = promotedRoot ? taskRegistry.taskForRoot(promotedRoot) : undefined
|
|
988
|
+
const taskCtx =
|
|
989
|
+
taskRec && taskRec.phase !== 'reserved'
|
|
990
|
+
? {
|
|
991
|
+
assignee: taskRec.assignee,
|
|
992
|
+
isRoot: !inboundRel && evt.event_id === taskRec.threadRoot,
|
|
993
|
+
}
|
|
994
|
+
: undefined
|
|
995
|
+
let matches = route(evt, bindings, threadStates, taskCtx)
|
|
996
|
+
// In a delegated task, agent-to-agent messages dispatch only when the
|
|
997
|
+
// outgoing flush registered a matching invocation. This prevents a
|
|
998
|
+
// circular handoff that was visibly refused from still waking its target.
|
|
999
|
+
if (taskCtx && !taskCtx.isRoot && evt.event_id && bindings.some((b) => b.userId === evt.sender)) {
|
|
1000
|
+
const invocation = invocations.byCallEvent(evt.event_id)
|
|
1001
|
+
matches = invocation ? matches.filter((match) => match.name === invocation.calleeAgent) : []
|
|
1002
|
+
}
|
|
1003
|
+
// [[ZOD039]] A return fires at the callee's turn boundary, not per message.
|
|
1004
|
+
// Every tool call forces a buffer flush, so one turn posts many
|
|
1005
|
+
// `m.room.message`s; routing each as a return woke the caller once per
|
|
1006
|
+
// chunk and the pair read as re-triggering each other. Hold them and let
|
|
1007
|
+
// the sender's `dev.zooid.turn.end` release the lot as a single wake.
|
|
1008
|
+
if (evt.type === 'm.room.message' && promotedRoot && evt.room_id) {
|
|
1009
|
+
const senderBinding = bindings.find((b) => b.userId === evt.sender)
|
|
1010
|
+
if (senderBinding) {
|
|
1011
|
+
const st = threadStates.get(promotedRoot)
|
|
1012
|
+
const held = matches.filter((m) => isReturnRoute(evt, m, bindings, st))
|
|
1013
|
+
if (held.length > 0) {
|
|
1014
|
+
matches = matches.filter((m) => !held.includes(m))
|
|
1015
|
+
stashReturn(senderBinding, promotedRoot, evt.room_id, evt, held)
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
// Anything we are waking now supersedes a return it was owed: an explicit
|
|
1019
|
+
// @mention (rule 1) or a human follow-up already carries the thread on.
|
|
1020
|
+
for (const m of matches) dropPendingReturn(promotedRoot, m.name)
|
|
1021
|
+
}
|
|
1022
|
+
|
|
680
1023
|
// Suppress the no-match warning for events sent by our own bots.
|
|
681
1024
|
const senderIsBot = bindings.some((b) => b.userId === evt.sender)
|
|
682
1025
|
if (evt.type === 'm.room.message' && matches.length === 0 && !senderIsBot) {
|
|
@@ -692,65 +1035,46 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
692
1035
|
st = { participants: [], rootMentions: [], callers: {}, handoffs: {} }
|
|
693
1036
|
threadStates.set(promotedRoot, st)
|
|
694
1037
|
}
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
st.
|
|
703
|
-
//
|
|
704
|
-
//
|
|
705
|
-
//
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
1038
|
+
if (taskCtx?.isRoot) {
|
|
1039
|
+
if (!st.rootMentions.includes(taskRec!.assignee)) st.rootMentions.push(taskRec!.assignee)
|
|
1040
|
+
} else {
|
|
1041
|
+
const msgMentions = new Set(extractMentions(evt as never))
|
|
1042
|
+
const senderAgent = bindings.find((b) => b.userId === evt.sender)
|
|
1043
|
+
for (const a of bindings) {
|
|
1044
|
+
if (!msgMentions.has(a.userId)) continue
|
|
1045
|
+
if (!st.rootMentions.includes(a.name)) st.rootMentions.push(a.name)
|
|
1046
|
+
// A mention that would close a cycle is a return addressed by name,
|
|
1047
|
+
// not a call: record no edge and mint no arc, or the pair bounces
|
|
1048
|
+
// forever and the callee loses its session to a fresh arc.
|
|
1049
|
+
if (
|
|
1050
|
+
senderAgent &&
|
|
1051
|
+
a.name !== senderAgent.name &&
|
|
1052
|
+
!wouldCycleCallers(st.callers, a.name, senderAgent.name)
|
|
1053
|
+
) {
|
|
1054
|
+
st.callers[a.name] = senderAgent.name
|
|
1055
|
+
if (evt.event_id) {
|
|
1056
|
+
const arcs = (st.handoffs[a.name] ??= [])
|
|
1057
|
+
if (!arcs.includes(evt.event_id)) arcs.push(evt.event_id)
|
|
1058
|
+
}
|
|
710
1059
|
}
|
|
711
1060
|
}
|
|
712
1061
|
}
|
|
713
1062
|
}
|
|
714
1063
|
for (const a of matches) {
|
|
715
1064
|
console.log(`[matrix] → ${a.name} (${a.userId})`)
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
const threadRoot = inboundThreadRoot(evt) ?? evt.event_id
|
|
730
|
-
if (!threadRoot || !evt.room_id) return
|
|
731
|
-
const body = toErrorBody(
|
|
732
|
-
{
|
|
733
|
-
kind: 'error',
|
|
734
|
-
agentId: a.name,
|
|
735
|
-
sessionId: null,
|
|
736
|
-
turnId: null,
|
|
737
|
-
code: c.code,
|
|
738
|
-
message: err instanceof Error ? err.message : String(err),
|
|
739
|
-
detail: err instanceof Error && err.stack ? err.stack.slice(0, 2000) : undefined,
|
|
740
|
-
transient: c.transient,
|
|
741
|
-
acp_error: c.acp_error,
|
|
742
|
-
},
|
|
743
|
-
threadRoot,
|
|
744
|
-
)
|
|
745
|
-
void client
|
|
746
|
-
.sendCustomEvent({
|
|
747
|
-
roomId: evt.room_id,
|
|
748
|
-
asUserId: a.userId,
|
|
749
|
-
eventType: 'dev.zooid.error',
|
|
750
|
-
content: body,
|
|
751
|
-
})
|
|
752
|
-
.catch((e) => console.warn(`[matrix:${a.name}] dev.zooid.error send failed:`, e))
|
|
753
|
-
})
|
|
1065
|
+
if (!promotedRoot || !evt.room_id) continue
|
|
1066
|
+
const sessionKey = sessionKeyFor(a.name, promotedRoot, threadStates.get(promotedRoot))
|
|
1067
|
+
const taskEnvelope =
|
|
1068
|
+
taskCtx?.isRoot && a.name === taskRec!.assignee
|
|
1069
|
+
? { parentAgent: taskRec!.parent.agent }
|
|
1070
|
+
: undefined
|
|
1071
|
+
void enqueueTurn(a, {
|
|
1072
|
+
roomId: evt.room_id,
|
|
1073
|
+
threadRoot: promotedRoot,
|
|
1074
|
+
sessionKey,
|
|
1075
|
+
event: evt,
|
|
1076
|
+
...(taskEnvelope ? { taskEnvelope } : {}),
|
|
1077
|
+
})
|
|
754
1078
|
}
|
|
755
1079
|
}
|
|
756
1080
|
|
|
@@ -768,7 +1092,9 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
768
1092
|
if (!authOk(c.req.header('authorization'))) {
|
|
769
1093
|
return c.json({ errcode: 'M_FORBIDDEN' }, 403)
|
|
770
1094
|
}
|
|
771
|
-
const body = (await c.req.json().catch(() => ({}))) as {
|
|
1095
|
+
const body = (await c.req.json().catch(() => ({}))) as {
|
|
1096
|
+
events?: MatrixEvent[]
|
|
1097
|
+
}
|
|
772
1098
|
for (const evt of body.events ?? []) {
|
|
773
1099
|
await handleInboundEvent(evt)
|
|
774
1100
|
}
|
|
@@ -795,19 +1121,16 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
795
1121
|
})
|
|
796
1122
|
app.get('/healthz', (c) => c.text('ok'))
|
|
797
1123
|
|
|
798
|
-
async function runTurn(agent: AgentBinding,
|
|
799
|
-
|
|
800
|
-
const inbound = inboundThreadRoot(evt)
|
|
1124
|
+
async function runTurn(agent: AgentBinding, input: TurnInput): Promise<void> {
|
|
1125
|
+
const { roomId, threadRoot, sessionKey } = input
|
|
801
1126
|
// Agent-promotion: top-level inbound becomes a thread root via the agent's
|
|
802
1127
|
// first reply.
|
|
803
|
-
const threadRoot = inbound ?? evt.event_id
|
|
804
1128
|
// [[ZOD071]]: the session key is the agent's current handoff arc when it
|
|
805
1129
|
// has one, else the thread-level key. The raw threadRoot still travels
|
|
806
1130
|
// separately: outbound events relate to it, and it is the context ref so
|
|
807
1131
|
// zooid_get_history reads the real thread.
|
|
808
|
-
const
|
|
809
|
-
|
|
810
|
-
sessions.set(sessionId, { agent, roomId: evt.room_id, threadRoot })
|
|
1132
|
+
const sessionId = await agents.ensureSession(agent.name, sessionKey, roomId, threadRoot)
|
|
1133
|
+
sessions.set(sessionId, { agent, roomId, threadRoot })
|
|
811
1134
|
buffers.set(sessionId, '')
|
|
812
1135
|
bufferMessageIds.delete(sessionId)
|
|
813
1136
|
flushedCounts.set(sessionId, 0)
|
|
@@ -820,12 +1143,16 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
820
1143
|
void agents.onEvent?.(agent.name, stashedCommands)
|
|
821
1144
|
}
|
|
822
1145
|
|
|
823
|
-
const roomId = evt.room_id
|
|
824
1146
|
const TYPING_TTL_MS = 30_000
|
|
825
1147
|
const TYPING_REFRESH_MS = 25_000
|
|
826
1148
|
const safeTyping = (typing: boolean) =>
|
|
827
1149
|
client
|
|
828
|
-
.setTyping({
|
|
1150
|
+
.setTyping({
|
|
1151
|
+
roomId,
|
|
1152
|
+
asUserId: agent.userId,
|
|
1153
|
+
typing,
|
|
1154
|
+
timeoutMs: TYPING_TTL_MS,
|
|
1155
|
+
})
|
|
829
1156
|
.catch((err) => console.warn(`[matrix:${agent.name}] setTyping(${typing}) failed:`, err))
|
|
830
1157
|
const safePresence = (presence: 'online' | 'unavailable' | 'offline') =>
|
|
831
1158
|
client
|
|
@@ -840,15 +1167,23 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
840
1167
|
void safeTyping(true)
|
|
841
1168
|
}, TYPING_REFRESH_MS)
|
|
842
1169
|
|
|
1170
|
+
let turnError: unknown
|
|
1171
|
+
let stopReason: StopReason | undefined
|
|
843
1172
|
try {
|
|
844
|
-
const rawBody =
|
|
845
|
-
const
|
|
1173
|
+
const rawBody = input.event?.content?.body ?? ''
|
|
1174
|
+
const strippedPromptText = input.promptText ?? stripMention(rawBody, agent.userId)
|
|
1175
|
+
const promptText = input.taskEnvelope
|
|
1176
|
+
? renderAssigneeEnvelope({
|
|
1177
|
+
parentAgent: input.taskEnvelope.parentAgent,
|
|
1178
|
+
prompt: strippedPromptText,
|
|
1179
|
+
})
|
|
1180
|
+
: strippedPromptText
|
|
846
1181
|
|
|
847
1182
|
// Drain pending media for this sender+thread and prepend as ACP content blocks.
|
|
848
1183
|
const pendingItems = pendingMedia.drain(
|
|
849
|
-
|
|
850
|
-
inboundThreadRoot(
|
|
851
|
-
|
|
1184
|
+
roomId,
|
|
1185
|
+
input.event ? inboundThreadRoot(input.event) : undefined,
|
|
1186
|
+
input.event?.sender ?? '',
|
|
852
1187
|
)
|
|
853
1188
|
const { blocks, pathLines } = await buildMediaBlocks(pendingItems, {
|
|
854
1189
|
agent,
|
|
@@ -857,7 +1192,7 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
857
1192
|
onError: (item, err) => {
|
|
858
1193
|
console.warn(`[matrix:${agent.name}] media_failed for ${item.body}:`, err)
|
|
859
1194
|
void sendMediaError(
|
|
860
|
-
{ agent, roomId
|
|
1195
|
+
{ agent, roomId, threadRoot },
|
|
861
1196
|
err,
|
|
862
1197
|
`Could not process attachment: ${item.body}`,
|
|
863
1198
|
client,
|
|
@@ -866,12 +1201,13 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
866
1201
|
})
|
|
867
1202
|
|
|
868
1203
|
const fullPromptText = [promptText, ...pathLines].filter(Boolean).join('\n')
|
|
869
|
-
await agents.prompt(agent.name, {
|
|
1204
|
+
const promptResult = await agents.prompt(agent.name, {
|
|
870
1205
|
threadId: sessionKey,
|
|
871
|
-
channelId:
|
|
1206
|
+
channelId: roomId,
|
|
872
1207
|
contextThreadId: threadRoot,
|
|
873
1208
|
content: [...blocks, { type: 'text', text: fullPromptText }],
|
|
874
1209
|
})
|
|
1210
|
+
stopReason = promptResult.stopReason as StopReason
|
|
875
1211
|
// Drain: the prompt promise resolves on the stopReason response, but
|
|
876
1212
|
// trailing chunks may still arrive (see DRAIN_* above). Wait until the
|
|
877
1213
|
// buffer is quiet for DRAIN_QUIET_MS, re-arming on each new chunk.
|
|
@@ -893,32 +1229,325 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
893
1229
|
// last message was flushed mid-stream). An unchanged *empty* buffer
|
|
894
1230
|
// with nothing flushed yet means the stream hasn't started; keep
|
|
895
1231
|
// waiting up to drainMaxMs.
|
|
896
|
-
if (next === drained && (next.length > 0 || (flushedCounts.get(sessionId) ?? 0) > 0))
|
|
897
|
-
break
|
|
1232
|
+
if (next === drained && (next.length > 0 || (flushedCounts.get(sessionId) ?? 0) > 0)) break
|
|
898
1233
|
drained = next
|
|
899
1234
|
}
|
|
900
1235
|
// Flush the final assistant message — the one with no following messageId
|
|
901
1236
|
// change or out-of-band event to have triggered an earlier flush.
|
|
902
1237
|
flushBuffer(sessionId)
|
|
1238
|
+
} catch (err) {
|
|
1239
|
+
turnError = err
|
|
1240
|
+
throw err
|
|
1241
|
+
} finally {
|
|
1242
|
+
clearInterval(refresh)
|
|
1243
|
+
await safeTyping(false)
|
|
1244
|
+
await safePresence('online')
|
|
903
1245
|
// Wait for every queued send (mid-turn flushes, tool/plan events, final
|
|
904
|
-
// flush) to settle before
|
|
1246
|
+
// flush) to settle before announcing the turn's end — and run this even
|
|
1247
|
+
// when the turn above threw, so the room never hangs on a spinner.
|
|
905
1248
|
await (sendQueue.get(sessionId) ?? Promise.resolve())
|
|
906
|
-
|
|
1249
|
+
const producedOutput = (flushedCounts.get(sessionId) ?? 0) > 0
|
|
1250
|
+
if (!producedOutput) {
|
|
907
1251
|
console.warn(
|
|
908
|
-
`[matrix:${agent.name}] turn finished with empty buffer (session=${sessionId}); nothing sent to ${
|
|
1252
|
+
`[matrix:${agent.name}] turn finished with empty buffer (session=${sessionId}); nothing sent to ${roomId}`,
|
|
909
1253
|
)
|
|
910
1254
|
}
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
1255
|
+
// Turn boundary for [[ZOD076]] and push notifications. Sent after the
|
|
1256
|
+
// send queue drains so it lands *after* the prose it announces — a
|
|
1257
|
+
// turn.end arriving first would notify the user to look at a room that
|
|
1258
|
+
// has nothing in it yet.
|
|
1259
|
+
await client
|
|
1260
|
+
.sendCustomEvent({
|
|
1261
|
+
roomId,
|
|
1262
|
+
asUserId: agent.userId,
|
|
1263
|
+
eventType: 'dev.zooid.turn.end',
|
|
1264
|
+
content: toTurnEndBody(
|
|
1265
|
+
{
|
|
1266
|
+
agentId: agent.name,
|
|
1267
|
+
sessionId,
|
|
1268
|
+
producedOutput,
|
|
1269
|
+
lastMessage: lastFlushed.get(sessionId),
|
|
1270
|
+
},
|
|
1271
|
+
threadRoot,
|
|
1272
|
+
),
|
|
1273
|
+
})
|
|
1274
|
+
.catch((e) => console.warn(`[matrix:${agent.name}] turn.end send failed:`, e))
|
|
1275
|
+
const task = taskRegistry.taskForRoot(threadRoot)
|
|
1276
|
+
const invocation = invocations.forCalleeSession(sessionKey)
|
|
1277
|
+
const isAssignee = task?.phase === 'open' && task.assignee === agent.name && task.threadRoot === sessionKey
|
|
1278
|
+
if (task?.phase === 'open' && (isAssignee || invocation?.state === 'outstanding')) {
|
|
1279
|
+
const decision = evaluateCompletion({
|
|
1280
|
+
agent: agent.name,
|
|
1281
|
+
threadId: isAssignee ? threadRoot : (invocation?.calleeSessionKey ?? sessionKey),
|
|
1282
|
+
stopReason,
|
|
1283
|
+
error: turnError,
|
|
1284
|
+
summary: isAssignee ? task.summary : undefined,
|
|
1285
|
+
prose: lastFlushed.get(sessionId),
|
|
1286
|
+
outstanding: invocations.outstandingFor(sessionKey).length,
|
|
1287
|
+
awaitingHuman: pendingInput.countFor(sessionKey),
|
|
1288
|
+
})
|
|
1289
|
+
if (decision.decision === 'finish') {
|
|
1290
|
+
if (isAssignee) await finishTask(task, { agent, completion: decision.completion })
|
|
1291
|
+
else if (invocation) returnInvocation(invocation, decision.completion, task)
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
915
1294
|
buffers.delete(sessionId)
|
|
916
1295
|
bufferMessageIds.delete(sessionId)
|
|
917
1296
|
flushedCounts.delete(sessionId)
|
|
1297
|
+
lastFlushed.delete(sessionId)
|
|
918
1298
|
sendQueue.delete(sessionId)
|
|
919
1299
|
}
|
|
920
1300
|
}
|
|
921
1301
|
|
|
1302
|
+
async function finishTask(
|
|
1303
|
+
task: TaskRecord,
|
|
1304
|
+
ctx: { agent: AgentBinding; completion: ThreadCompletion },
|
|
1305
|
+
): Promise<void> {
|
|
1306
|
+
const threadId = task.threadRoot!
|
|
1307
|
+
const completion = ctx.completion
|
|
1308
|
+
if (!taskRegistry.close(task.taskId)) return
|
|
1309
|
+
const cancelled = invocations.cancelForTask(task.taskId)
|
|
1310
|
+
pendingInput.cancelFor([threadId, ...cancelled.map((i) => i.calleeSessionKey).filter((x): x is string => Boolean(x))])
|
|
1311
|
+
await client.sendCustomEvent({
|
|
1312
|
+
roomId: task.roomId,
|
|
1313
|
+
asUserId: ctx.agent.userId,
|
|
1314
|
+
eventType: THREAD_RESULT_FIELD,
|
|
1315
|
+
content: {
|
|
1316
|
+
...completion,
|
|
1317
|
+
'm.relates_to': { rel_type: 'm.thread', event_id: threadId },
|
|
1318
|
+
},
|
|
1319
|
+
})
|
|
1320
|
+
if (task.summary && task.summary !== completion.output?.text)
|
|
1321
|
+
await client.sendMessage({
|
|
1322
|
+
roomId: task.roomId,
|
|
1323
|
+
asUserId: ctx.agent.userId,
|
|
1324
|
+
threadRoot: threadId,
|
|
1325
|
+
content: buildTextContent(task.summary),
|
|
1326
|
+
})
|
|
1327
|
+
if (task.notify === 'none') return
|
|
1328
|
+
const parent = bindingFor(task.parent.agent)
|
|
1329
|
+
await client.sendMessage({
|
|
1330
|
+
roomId: task.roomId,
|
|
1331
|
+
asUserId: ctx.agent.userId,
|
|
1332
|
+
threadRoot: task.parent.threadRoot,
|
|
1333
|
+
content: {
|
|
1334
|
+
msgtype: 'm.notice',
|
|
1335
|
+
body: renderCompletionPrompt(completion),
|
|
1336
|
+
[THREAD_RESULT_FIELD]: completion,
|
|
1337
|
+
},
|
|
1338
|
+
})
|
|
1339
|
+
if (
|
|
1340
|
+
!parent ||
|
|
1341
|
+
taskRegistry.generationOf(task.parent.agent, task.parent.sessionKey) !==
|
|
1342
|
+
task.parent.generation
|
|
1343
|
+
)
|
|
1344
|
+
return
|
|
1345
|
+
void enqueueTurn(parent, {
|
|
1346
|
+
roomId: task.roomId,
|
|
1347
|
+
threadRoot: task.parent.threadRoot,
|
|
1348
|
+
sessionKey: task.parent.sessionKey,
|
|
1349
|
+
promptText: renderCompletionPrompt(completion),
|
|
1350
|
+
})
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
function returnInvocation(invocation: import('@zooid/core').InvocationRecord, completion: ThreadCompletion, task: TaskRecord): void {
|
|
1354
|
+
const resolved = invocations.resolve(invocation.invocationId)
|
|
1355
|
+
if (!resolved || task.phase !== 'open') return
|
|
1356
|
+
const caller = bindingFor(resolved.callerAgent)
|
|
1357
|
+
if (!caller || !task.threadRoot) return
|
|
1358
|
+
void enqueueTurn(caller, {
|
|
1359
|
+
roomId: task.roomId,
|
|
1360
|
+
threadRoot: task.threadRoot,
|
|
1361
|
+
sessionKey: resolved.callerSessionKey,
|
|
1362
|
+
promptText: renderInvocationReturn(completion),
|
|
1363
|
+
})
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
const taskActions: TaskActions = {
|
|
1367
|
+
async startTasks(caller, input) {
|
|
1368
|
+
const notify = input.notify ?? 'caller'
|
|
1369
|
+
const results: StartTaskResult[] = new Array(input.tasks.length)
|
|
1370
|
+
const callerBinding = bindingFor(caller.agentName)
|
|
1371
|
+
const enclosing = taskRegistry.taskForRoot(caller.threadRoot)
|
|
1372
|
+
const admitted: Array<{
|
|
1373
|
+
index: number
|
|
1374
|
+
spec: StartTaskSpec
|
|
1375
|
+
rec: TaskRecord
|
|
1376
|
+
}> = []
|
|
1377
|
+
for (const [index, spec] of input.tasks.entries()) {
|
|
1378
|
+
if (!callerBinding) {
|
|
1379
|
+
results[index] = {
|
|
1380
|
+
agent: spec.agent,
|
|
1381
|
+
status: 'refused',
|
|
1382
|
+
reason: 'unknown_caller',
|
|
1383
|
+
}
|
|
1384
|
+
continue
|
|
1385
|
+
}
|
|
1386
|
+
if (enclosing) {
|
|
1387
|
+
results[index] = {
|
|
1388
|
+
agent: spec.agent,
|
|
1389
|
+
status: 'refused',
|
|
1390
|
+
reason:
|
|
1391
|
+
'depth_limit: this thread is itself a delegated task. Do the work here, or @mention another agent in this thread to hand off.',
|
|
1392
|
+
}
|
|
1393
|
+
continue
|
|
1394
|
+
}
|
|
1395
|
+
const admission = checkDelegable(spec.agent, caller.channelId, bindings)
|
|
1396
|
+
if (!admission.ok) {
|
|
1397
|
+
results[index] = {
|
|
1398
|
+
agent: spec.agent,
|
|
1399
|
+
status: 'refused',
|
|
1400
|
+
reason: admission.reason,
|
|
1401
|
+
}
|
|
1402
|
+
continue
|
|
1403
|
+
}
|
|
1404
|
+
const rec = taskRegistry.reserve({
|
|
1405
|
+
roomId: caller.channelId,
|
|
1406
|
+
assignee: spec.agent,
|
|
1407
|
+
notify,
|
|
1408
|
+
parent: {
|
|
1409
|
+
agent: caller.agentName,
|
|
1410
|
+
threadRoot: caller.threadRoot,
|
|
1411
|
+
sessionKey: caller.sessionKey,
|
|
1412
|
+
generation: taskRegistry.generationOf(caller.agentName, caller.sessionKey),
|
|
1413
|
+
},
|
|
1414
|
+
})
|
|
1415
|
+
if (!rec) {
|
|
1416
|
+
results[index] = {
|
|
1417
|
+
agent: spec.agent,
|
|
1418
|
+
status: 'refused',
|
|
1419
|
+
reason: `at_capacity: ${MAX_OPEN_TASKS_PER_ROOM} tasks are already open in this room. Wait for one to finish.`,
|
|
1420
|
+
}
|
|
1421
|
+
continue
|
|
1422
|
+
}
|
|
1423
|
+
admitted.push({ index, spec, rec })
|
|
1424
|
+
}
|
|
1425
|
+
await Promise.all(
|
|
1426
|
+
admitted.map(async ({ index, spec, rec }) => {
|
|
1427
|
+
const assignee = bindingFor(spec.agent)!
|
|
1428
|
+
const content = buildAssignmentContent({
|
|
1429
|
+
assigneeUserId: assignee.userId,
|
|
1430
|
+
prompt: spec.prompt,
|
|
1431
|
+
start: {
|
|
1432
|
+
version: 1,
|
|
1433
|
+
assignee: spec.agent,
|
|
1434
|
+
attempt_id: rec.attemptId,
|
|
1435
|
+
parent: {
|
|
1436
|
+
agent: rec.parent.agent,
|
|
1437
|
+
thread_root: rec.parent.threadRoot,
|
|
1438
|
+
session_key: rec.parent.sessionKey,
|
|
1439
|
+
},
|
|
1440
|
+
notify,
|
|
1441
|
+
},
|
|
1442
|
+
})
|
|
1443
|
+
const post = () =>
|
|
1444
|
+
client.sendMessage({
|
|
1445
|
+
roomId: caller.channelId,
|
|
1446
|
+
asUserId: callerBinding!.userId,
|
|
1447
|
+
content,
|
|
1448
|
+
txnId: rec.attemptId,
|
|
1449
|
+
})
|
|
1450
|
+
try {
|
|
1451
|
+
const { event_id } = await post()
|
|
1452
|
+
taskRegistry.activate(rec.taskId, event_id)
|
|
1453
|
+
results[index] = {
|
|
1454
|
+
agent: spec.agent,
|
|
1455
|
+
status: 'started',
|
|
1456
|
+
thread_id: event_id,
|
|
1457
|
+
}
|
|
1458
|
+
} catch {
|
|
1459
|
+
try {
|
|
1460
|
+
const { event_id } = await post()
|
|
1461
|
+
taskRegistry.activate(rec.taskId, event_id)
|
|
1462
|
+
results[index] = {
|
|
1463
|
+
agent: spec.agent,
|
|
1464
|
+
status: 'started',
|
|
1465
|
+
thread_id: event_id,
|
|
1466
|
+
}
|
|
1467
|
+
} catch (second) {
|
|
1468
|
+
const status = (second as { status?: number }).status
|
|
1469
|
+
if (status !== undefined && status >= 400 && status < 500 && status !== 429) {
|
|
1470
|
+
taskRegistry.abandon(rec.taskId)
|
|
1471
|
+
results[index] = {
|
|
1472
|
+
agent: spec.agent,
|
|
1473
|
+
status: 'failed',
|
|
1474
|
+
reason: `post_failed: ${String((second as Error).message)}`,
|
|
1475
|
+
}
|
|
1476
|
+
} else {
|
|
1477
|
+
taskRegistry.markUncertain(rec.taskId)
|
|
1478
|
+
results[index] = {
|
|
1479
|
+
agent: spec.agent,
|
|
1480
|
+
status: 'failed',
|
|
1481
|
+
reason: `post_uncertain: ${String((second as Error).message)}`,
|
|
1482
|
+
attempt_id: rec.attemptId,
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
}),
|
|
1488
|
+
)
|
|
1489
|
+
return { results, notify, delivery: renderDelivery(notify) }
|
|
1490
|
+
},
|
|
1491
|
+
async completeTask(caller, input) {
|
|
1492
|
+
const summary = input.summary.trim()
|
|
1493
|
+
if (!summary) return { status: 'refused', reason: 'summary must be non-empty' }
|
|
1494
|
+
const rec = taskRegistry.openTaskFor(caller.agentName, caller.threadRoot)
|
|
1495
|
+
if (!rec || rec.threadRoot !== caller.sessionKey)
|
|
1496
|
+
return {
|
|
1497
|
+
status: 'refused',
|
|
1498
|
+
reason: 'no_open_task: this session is not the assignee of an open task',
|
|
1499
|
+
}
|
|
1500
|
+
if (invocations.outstandingFor(caller.sessionKey).length)
|
|
1501
|
+
return { status: 'refused', reason: 'outstanding_handoff: wait for delegated work to return' }
|
|
1502
|
+
return { status: taskRegistry.recordSummary(rec.taskId, summary) }
|
|
1503
|
+
},
|
|
1504
|
+
async describeRole(caller) {
|
|
1505
|
+
const enclosing = taskRegistry.taskForRoot(caller.threadRoot)
|
|
1506
|
+
const openTask = taskRegistry.openTaskFor(caller.agentName, caller.threadRoot)
|
|
1507
|
+
return {
|
|
1508
|
+
is_task_assignee: openTask !== undefined && openTask.threadRoot === caller.sessionKey,
|
|
1509
|
+
can_start_task_threads: enclosing === undefined,
|
|
1510
|
+
}
|
|
1511
|
+
},
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
// Journal reconciliation happens after functions are initialized, but before
|
|
1515
|
+
// the daemon starts accepting work. A prior run has no ACP turn to supply a
|
|
1516
|
+
// terminal boundary, so publish its durable cancellation directly.
|
|
1517
|
+
queueMicrotask(() => {
|
|
1518
|
+
for (const task of interruptedTasks) {
|
|
1519
|
+
if (!task.threadRoot) continue
|
|
1520
|
+
const assignee = bindingFor(task.assignee)
|
|
1521
|
+
if (!assignee) continue
|
|
1522
|
+
const completion: ThreadCompletion = {
|
|
1523
|
+
agent: task.assignee, thread_id: task.threadRoot, status: 'cancelled', reason: 'interrupted_by_restart',
|
|
1524
|
+
}
|
|
1525
|
+
void client.sendCustomEvent({
|
|
1526
|
+
roomId: task.roomId, asUserId: assignee.userId, eventType: THREAD_RESULT_FIELD,
|
|
1527
|
+
content: { ...completion, 'm.relates_to': { rel_type: 'm.thread', event_id: task.threadRoot } },
|
|
1528
|
+
})
|
|
1529
|
+
if (task.notify !== 'none') {
|
|
1530
|
+
const parent = bindingFor(task.parent.agent)
|
|
1531
|
+
if (
|
|
1532
|
+
parent &&
|
|
1533
|
+
taskRegistry.generationOf(task.parent.agent, task.parent.sessionKey) === task.parent.generation
|
|
1534
|
+
) {
|
|
1535
|
+
void client.sendMessage({
|
|
1536
|
+
roomId: task.roomId,
|
|
1537
|
+
asUserId: assignee.userId,
|
|
1538
|
+
threadRoot: task.parent.threadRoot,
|
|
1539
|
+
content: {
|
|
1540
|
+
msgtype: 'm.notice',
|
|
1541
|
+
body: renderCompletionPrompt(completion),
|
|
1542
|
+
[THREAD_RESULT_FIELD]: completion,
|
|
1543
|
+
},
|
|
1544
|
+
})
|
|
1545
|
+
void enqueueTurn(parent, { roomId: task.roomId, threadRoot: task.parent.threadRoot, sessionKey: task.parent.sessionKey, promptText: renderCompletionPrompt(completion) })
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
})
|
|
1550
|
+
|
|
922
1551
|
const syncLoops: SyncLoop[] | undefined =
|
|
923
1552
|
mode === 'client'
|
|
924
1553
|
? bindings.map(
|
|
@@ -935,6 +1564,7 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
935
1564
|
|
|
936
1565
|
return {
|
|
937
1566
|
app,
|
|
1567
|
+
taskActions,
|
|
938
1568
|
syncLoops,
|
|
939
1569
|
bootstrap: async (
|
|
940
1570
|
bootstrapOpts: {
|
|
@@ -968,11 +1598,17 @@ export async function rebuildThreadState(
|
|
|
968
1598
|
rootEventId: string,
|
|
969
1599
|
bindings: AgentBinding[],
|
|
970
1600
|
): Promise<ThreadState> {
|
|
971
|
-
const state: ThreadState = {
|
|
1601
|
+
const state: ThreadState = {
|
|
1602
|
+
participants: [],
|
|
1603
|
+
rootMentions: [],
|
|
1604
|
+
callers: {},
|
|
1605
|
+
handoffs: {},
|
|
1606
|
+
}
|
|
972
1607
|
// Impersonate an agent that's actually a member of this room (AS reads
|
|
973
1608
|
// require room membership). Falling through to the first binding would
|
|
974
1609
|
// 403 if that agent never joined the target room.
|
|
975
|
-
const asUser = (bindings.find((b) => b.rooms.some((r) => r.alias === roomId)) ?? bindings[0])
|
|
1610
|
+
const asUser = (bindings.find((b) => b.rooms.some((r) => r.alias === roomId)) ?? bindings[0])
|
|
1611
|
+
?.userId
|
|
976
1612
|
if (!asUser) return state
|
|
977
1613
|
|
|
978
1614
|
const root = await client.fetchEvent(roomId, rootEventId, asUser)
|
|
@@ -983,7 +1619,11 @@ export async function rebuildThreadState(
|
|
|
983
1619
|
for (const a of bindings) {
|
|
984
1620
|
if (!rootMentions.has(a.userId)) continue
|
|
985
1621
|
if (!state.rootMentions.includes(a.name)) state.rootMentions.push(a.name)
|
|
986
|
-
if (
|
|
1622
|
+
if (
|
|
1623
|
+
rootSenderAgent &&
|
|
1624
|
+
a.name !== rootSenderAgent.name &&
|
|
1625
|
+
!wouldCycleCallers(state.callers, a.name, rootSenderAgent.name)
|
|
1626
|
+
) {
|
|
987
1627
|
state.callers[a.name] = rootSenderAgent.name
|
|
988
1628
|
const arcs = (state.handoffs[a.name] ??= [])
|
|
989
1629
|
if (!arcs.includes(rootEventId)) arcs.push(rootEventId)
|
|
@@ -1005,7 +1645,11 @@ export async function rebuildThreadState(
|
|
|
1005
1645
|
for (const a of bindings) {
|
|
1006
1646
|
if (!mentions.has(a.userId)) continue
|
|
1007
1647
|
if (!state.rootMentions.includes(a.name)) state.rootMentions.push(a.name)
|
|
1008
|
-
if (
|
|
1648
|
+
if (
|
|
1649
|
+
evSenderAgent &&
|
|
1650
|
+
a.name !== evSenderAgent.name &&
|
|
1651
|
+
!wouldCycleCallers(state.callers, a.name, evSenderAgent.name)
|
|
1652
|
+
) {
|
|
1009
1653
|
state.callers[a.name] = evSenderAgent.name
|
|
1010
1654
|
if (evId) {
|
|
1011
1655
|
const arcs = (state.handoffs[a.name] ??= [])
|