@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/dist/index.d.ts +152 -4
- package/dist/index.js +898 -94
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/context-provider.test.ts +61 -4
- package/src/context-provider.ts +40 -2
- 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 +403 -114
- package/src/transport.ts +719 -110
package/src/transport.ts
CHANGED
|
@@ -1,10 +1,28 @@
|
|
|
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
28
|
import {
|
|
@@ -17,17 +35,22 @@ import {
|
|
|
17
35
|
} from './event-encoders.js'
|
|
18
36
|
import { classify } from '@zooid/acp-client'
|
|
19
37
|
import { toMatrixHtml } from './markdown-to-matrix-html.js'
|
|
20
|
-
import {
|
|
21
|
-
|
|
22
|
-
type PendingMediaItem,
|
|
23
|
-
} from './pending-media.js'
|
|
24
|
-
import {
|
|
25
|
-
MediaClient,
|
|
26
|
-
MAX_INLINE_IMAGE_BYTES,
|
|
27
|
-
INLINE_IMAGE_MIMES,
|
|
28
|
-
} 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'
|
|
29
40
|
import { writeAttachment } from './attachments.js'
|
|
30
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'
|
|
31
54
|
|
|
32
55
|
export interface MediaClientLike {
|
|
33
56
|
download(input: {
|
|
@@ -75,6 +98,12 @@ export interface CreateMatrixTransportOptions {
|
|
|
75
98
|
loadSince?: (agentUserId: string) => string | null
|
|
76
99
|
/** Pull mode: persist the `since` cursor after each sync poll. */
|
|
77
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
|
|
78
107
|
}
|
|
79
108
|
|
|
80
109
|
interface SessionContext {
|
|
@@ -84,6 +113,32 @@ interface SessionContext {
|
|
|
84
113
|
threadRoot: string
|
|
85
114
|
}
|
|
86
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
|
+
|
|
87
142
|
interface MatrixEvent {
|
|
88
143
|
type?: string
|
|
89
144
|
event_id?: string
|
|
@@ -102,6 +157,15 @@ interface MatrixEvent {
|
|
|
102
157
|
|
|
103
158
|
const STARTUP_GRACE_MS = 5_000
|
|
104
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
|
+
|
|
105
169
|
interface MediaBlocksResult {
|
|
106
170
|
blocks: ContentBlock[]
|
|
107
171
|
pathLines: string[]
|
|
@@ -242,9 +306,19 @@ function inboundThreadRoot(evt: MatrixEvent): string | undefined {
|
|
|
242
306
|
}
|
|
243
307
|
|
|
244
308
|
export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
245
|
-
const {
|
|
309
|
+
const {
|
|
310
|
+
agents,
|
|
311
|
+
approvals,
|
|
312
|
+
client,
|
|
313
|
+
bindings,
|
|
314
|
+
hsToken,
|
|
315
|
+
adminUserId,
|
|
316
|
+
botUserId,
|
|
317
|
+
mode = 'appservice',
|
|
318
|
+
} = opts
|
|
246
319
|
const drainQuietMs = opts.drainQuietMs ?? DRAIN_QUIET_MS
|
|
247
320
|
const drainMaxMs = opts.drainMaxMs ?? DRAIN_MAX_MS
|
|
321
|
+
const returnGraceMs = opts.returnGraceMs ?? RETURN_GRACE_MS
|
|
248
322
|
const mediaClient = opts.media
|
|
249
323
|
const writeAttachmentFn = opts.writeAttachmentFn ?? writeAttachment
|
|
250
324
|
const pendingMedia = new PendingMediaStore()
|
|
@@ -267,6 +341,91 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
267
341
|
const sendQueue = new Map<string, Promise<void>>()
|
|
268
342
|
// Thread participation index: keyed by thread root event_id.
|
|
269
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
|
+
}
|
|
270
429
|
// Drop events older than this — in push (appservice) mode Tuwunel may replay
|
|
271
430
|
// a backlog after the daemon was offline, and we don't want yesterday's
|
|
272
431
|
// "@docs hi" to fire now. In pull (client) mode the persisted `since` cursor
|
|
@@ -305,9 +464,7 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
305
464
|
const html = toMatrixHtml(text)
|
|
306
465
|
if (html) {
|
|
307
466
|
const escapedPlain =
|
|
308
|
-
'<p>' +
|
|
309
|
-
text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') +
|
|
310
|
-
'</p>'
|
|
467
|
+
'<p>' + text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') + '</p>'
|
|
311
468
|
const norm = (s: string) => s.replace(/\s+/g, ' ').trim()
|
|
312
469
|
if (norm(html) !== norm(escapedPlain)) {
|
|
313
470
|
content.format = 'org.matrix.custom.html'
|
|
@@ -336,14 +493,21 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
336
493
|
lastFlushed.set(sessionId, text)
|
|
337
494
|
flushedCounts.set(sessionId, (flushedCounts.get(sessionId) ?? 0) + 1)
|
|
338
495
|
const content = buildTextContent(text)
|
|
496
|
+
const pendingInvocations = registerOutgoingHandoffs(sessionId, text)
|
|
339
497
|
const tail = (sendQueue.get(sessionId) ?? Promise.resolve()).then(async () => {
|
|
340
498
|
try {
|
|
341
|
-
await client.sendMessage({
|
|
499
|
+
const { event_id } = await client.sendMessage({
|
|
342
500
|
roomId: ctx.roomId,
|
|
343
501
|
asUserId: ctx.agent.userId,
|
|
344
502
|
content,
|
|
345
503
|
threadRoot: ctx.threadRoot,
|
|
346
504
|
})
|
|
505
|
+
for (const invocation of pendingInvocations)
|
|
506
|
+
invocations.attachCallEvent(
|
|
507
|
+
invocation.invocationId,
|
|
508
|
+
event_id,
|
|
509
|
+
composeHandoffKey(ctx.threadRoot, event_id),
|
|
510
|
+
)
|
|
347
511
|
} catch (err) {
|
|
348
512
|
console.warn(`[matrix:${ctx.agent.name}] sendMessage flush failed:`, err)
|
|
349
513
|
}
|
|
@@ -352,6 +516,29 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
352
516
|
return true
|
|
353
517
|
}
|
|
354
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
|
+
|
|
355
542
|
agents.onEvent = async (name, event: AgentEvent) => {
|
|
356
543
|
const ctx = sessions.get(event.sessionId)
|
|
357
544
|
if (!ctx) {
|
|
@@ -369,7 +556,12 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
369
556
|
}
|
|
370
557
|
|
|
371
558
|
if (event.type === 'agent_message_chunk') {
|
|
372
|
-
const block = event.content as {
|
|
559
|
+
const block = event.content as {
|
|
560
|
+
type?: string
|
|
561
|
+
text?: string
|
|
562
|
+
data?: string
|
|
563
|
+
mimeType?: string
|
|
564
|
+
}
|
|
373
565
|
if (block.type === 'text' && typeof block.text === 'string') {
|
|
374
566
|
// A change in ACP messageId marks the previous assistant message as
|
|
375
567
|
// complete. opencode streams each assistant message under its own id
|
|
@@ -382,8 +574,7 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
382
574
|
event.messageId !== undefined &&
|
|
383
575
|
prevMessageId !== undefined &&
|
|
384
576
|
event.messageId !== prevMessageId
|
|
385
|
-
if (event.messageId !== undefined)
|
|
386
|
-
bufferMessageIds.set(event.sessionId, event.messageId)
|
|
577
|
+
if (event.messageId !== undefined) bufferMessageIds.set(event.sessionId, event.messageId)
|
|
387
578
|
// flushBuffer clears the buffer synchronously, so the new message's
|
|
388
579
|
// text below starts fresh.
|
|
389
580
|
if (messageChanged) flushBuffer(event.sessionId)
|
|
@@ -407,7 +598,12 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
407
598
|
const ext = (block.mimeType.split('/')[1] ?? 'png').replace(/[^a-z0-9]/gi, '')
|
|
408
599
|
const filename = `image.${ext}`
|
|
409
600
|
void mediaClient
|
|
410
|
-
.upload({
|
|
601
|
+
.upload({
|
|
602
|
+
data: bytes,
|
|
603
|
+
contentType: block.mimeType,
|
|
604
|
+
filename,
|
|
605
|
+
asUserId: ctx.agent.userId,
|
|
606
|
+
})
|
|
411
607
|
.then(({ content_uri }) =>
|
|
412
608
|
client.sendMessage({
|
|
413
609
|
roomId: ctx.roomId,
|
|
@@ -486,7 +682,10 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
486
682
|
tool_call_id: handle.toolCallId,
|
|
487
683
|
options: handle.options,
|
|
488
684
|
}
|
|
489
|
-
content['m.relates_to'] = {
|
|
685
|
+
content['m.relates_to'] = {
|
|
686
|
+
rel_type: 'm.thread',
|
|
687
|
+
event_id: ctx.threadRoot,
|
|
688
|
+
}
|
|
490
689
|
if (handle.toolKind !== undefined) content.tool_kind = handle.toolKind
|
|
491
690
|
if (handle.toolTitle !== undefined) content.tool_title = handle.toolTitle
|
|
492
691
|
if (handle.toolInput !== undefined) content.tool_input = handle.toolInput
|
|
@@ -498,6 +697,58 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
498
697
|
})
|
|
499
698
|
})
|
|
500
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
|
+
|
|
501
752
|
async function handleInboundEvent(evt: MatrixEvent): Promise<void> {
|
|
502
753
|
if (evt.event_id) {
|
|
503
754
|
if (seenEventIds.has(evt.event_id)) {
|
|
@@ -554,6 +805,9 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
554
805
|
return
|
|
555
806
|
}
|
|
556
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)
|
|
557
811
|
// [[ZOD071]]: a thread's sessions are the thread-level one plus one per
|
|
558
812
|
// handoff arc — end them all. Reset events aren't m.room.message, so
|
|
559
813
|
// the self-heal rebuild above doesn't cover them; rebuild here if the
|
|
@@ -571,8 +825,11 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
571
825
|
const st = threadStates.get(threadRoot)
|
|
572
826
|
for (const a of bindings) {
|
|
573
827
|
agents.endSession(a.name, threadRoot)
|
|
828
|
+
taskRegistry.bumpGeneration(a.name, threadRoot)
|
|
574
829
|
for (const arc of st?.handoffs[a.name] ?? []) {
|
|
575
|
-
|
|
830
|
+
const key = composeHandoffKey(threadRoot, arc)
|
|
831
|
+
agents.endSession(a.name, key)
|
|
832
|
+
taskRegistry.bumpGeneration(a.name, key)
|
|
576
833
|
}
|
|
577
834
|
}
|
|
578
835
|
// NB: keep threadStates intact. Per ZOD039 § /clear, only the agent's
|
|
@@ -582,7 +839,10 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
582
839
|
return
|
|
583
840
|
}
|
|
584
841
|
if (evt.type === 'dev.zooid.interrupt') {
|
|
585
|
-
const content = (evt.content ?? {}) as {
|
|
842
|
+
const content = (evt.content ?? {}) as {
|
|
843
|
+
session_id?: string
|
|
844
|
+
reason?: string
|
|
845
|
+
}
|
|
586
846
|
// Thread-relation form (client-friendly): /interrupt in a thread sends
|
|
587
847
|
// an empty event with `m.relates_to: thread/<root>`. Cancel every
|
|
588
848
|
// session whose threadRoot matches.
|
|
@@ -607,6 +867,18 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
607
867
|
console.error(`[matrix] cancelSession(${t.agent}, ${t.sessionId}) failed:`, err)
|
|
608
868
|
})
|
|
609
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
|
+
}
|
|
610
882
|
return
|
|
611
883
|
}
|
|
612
884
|
// Legacy form: explicit session_id in content.
|
|
@@ -623,7 +895,10 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
623
895
|
(content.reason ? ` reason=${content.reason}` : ''),
|
|
624
896
|
)
|
|
625
897
|
await agents.cancelSession(ctx.agent.name, content.session_id).catch((err) => {
|
|
626
|
-
console.error(
|
|
898
|
+
console.error(
|
|
899
|
+
`[matrix] cancelSession(${ctx.agent.name}, ${content.session_id}) failed:`,
|
|
900
|
+
err,
|
|
901
|
+
)
|
|
627
902
|
})
|
|
628
903
|
return
|
|
629
904
|
}
|
|
@@ -638,16 +913,28 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
638
913
|
const decision = content.option_id
|
|
639
914
|
? { decision: content.decision, optionId: content.option_id }
|
|
640
915
|
: { decision: content.decision }
|
|
641
|
-
const ok = approvals.resolve(
|
|
642
|
-
content.session_id,
|
|
643
|
-
content.approval_id,
|
|
644
|
-
decision as never,
|
|
645
|
-
)
|
|
916
|
+
const ok = approvals.resolve(content.session_id, content.approval_id, decision as never)
|
|
646
917
|
if (!ok) console.warn(`[matrix] unknown approval ${content.approval_id}`)
|
|
647
918
|
return
|
|
648
919
|
}
|
|
649
920
|
logInbound(evt)
|
|
650
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
|
+
|
|
651
938
|
// Capture media events in the pending store; never route them to agents.
|
|
652
939
|
if (
|
|
653
940
|
evt.type === 'm.room.message' &&
|
|
@@ -693,7 +980,46 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
693
980
|
console.warn(`[matrix] failed to rebuild threadState for ${inboundRel}:`, err)
|
|
694
981
|
}
|
|
695
982
|
}
|
|
696
|
-
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
|
+
|
|
697
1023
|
// Suppress the no-match warning for events sent by our own bots.
|
|
698
1024
|
const senderIsBot = bindings.some((b) => b.userId === evt.sender)
|
|
699
1025
|
if (evt.type === 'm.room.message' && matches.length === 0 && !senderIsBot) {
|
|
@@ -709,65 +1035,46 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
709
1035
|
st = { participants: [], rootMentions: [], callers: {}, handoffs: {} }
|
|
710
1036
|
threadStates.set(promotedRoot, st)
|
|
711
1037
|
}
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
st.
|
|
720
|
-
//
|
|
721
|
-
//
|
|
722
|
-
//
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
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
|
+
}
|
|
727
1059
|
}
|
|
728
1060
|
}
|
|
729
1061
|
}
|
|
730
1062
|
}
|
|
731
1063
|
for (const a of matches) {
|
|
732
1064
|
console.log(`[matrix] → ${a.name} (${a.userId})`)
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
const threadRoot = inboundThreadRoot(evt) ?? evt.event_id
|
|
747
|
-
if (!threadRoot || !evt.room_id) return
|
|
748
|
-
const body = toErrorBody(
|
|
749
|
-
{
|
|
750
|
-
kind: 'error',
|
|
751
|
-
agentId: a.name,
|
|
752
|
-
sessionId: null,
|
|
753
|
-
turnId: null,
|
|
754
|
-
code: c.code,
|
|
755
|
-
message: err instanceof Error ? err.message : String(err),
|
|
756
|
-
detail: err instanceof Error && err.stack ? err.stack.slice(0, 2000) : undefined,
|
|
757
|
-
transient: c.transient,
|
|
758
|
-
acp_error: c.acp_error,
|
|
759
|
-
},
|
|
760
|
-
threadRoot,
|
|
761
|
-
)
|
|
762
|
-
void client
|
|
763
|
-
.sendCustomEvent({
|
|
764
|
-
roomId: evt.room_id,
|
|
765
|
-
asUserId: a.userId,
|
|
766
|
-
eventType: 'dev.zooid.error',
|
|
767
|
-
content: body,
|
|
768
|
-
})
|
|
769
|
-
.catch((e) => console.warn(`[matrix:${a.name}] dev.zooid.error send failed:`, e))
|
|
770
|
-
})
|
|
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
|
+
})
|
|
771
1078
|
}
|
|
772
1079
|
}
|
|
773
1080
|
|
|
@@ -785,7 +1092,9 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
785
1092
|
if (!authOk(c.req.header('authorization'))) {
|
|
786
1093
|
return c.json({ errcode: 'M_FORBIDDEN' }, 403)
|
|
787
1094
|
}
|
|
788
|
-
const body = (await c.req.json().catch(() => ({}))) as {
|
|
1095
|
+
const body = (await c.req.json().catch(() => ({}))) as {
|
|
1096
|
+
events?: MatrixEvent[]
|
|
1097
|
+
}
|
|
789
1098
|
for (const evt of body.events ?? []) {
|
|
790
1099
|
await handleInboundEvent(evt)
|
|
791
1100
|
}
|
|
@@ -812,19 +1121,16 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
812
1121
|
})
|
|
813
1122
|
app.get('/healthz', (c) => c.text('ok'))
|
|
814
1123
|
|
|
815
|
-
async function runTurn(agent: AgentBinding,
|
|
816
|
-
|
|
817
|
-
const inbound = inboundThreadRoot(evt)
|
|
1124
|
+
async function runTurn(agent: AgentBinding, input: TurnInput): Promise<void> {
|
|
1125
|
+
const { roomId, threadRoot, sessionKey } = input
|
|
818
1126
|
// Agent-promotion: top-level inbound becomes a thread root via the agent's
|
|
819
1127
|
// first reply.
|
|
820
|
-
const threadRoot = inbound ?? evt.event_id
|
|
821
1128
|
// [[ZOD071]]: the session key is the agent's current handoff arc when it
|
|
822
1129
|
// has one, else the thread-level key. The raw threadRoot still travels
|
|
823
1130
|
// separately: outbound events relate to it, and it is the context ref so
|
|
824
1131
|
// zooid_get_history reads the real thread.
|
|
825
|
-
const
|
|
826
|
-
|
|
827
|
-
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 })
|
|
828
1134
|
buffers.set(sessionId, '')
|
|
829
1135
|
bufferMessageIds.delete(sessionId)
|
|
830
1136
|
flushedCounts.set(sessionId, 0)
|
|
@@ -837,12 +1143,16 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
837
1143
|
void agents.onEvent?.(agent.name, stashedCommands)
|
|
838
1144
|
}
|
|
839
1145
|
|
|
840
|
-
const roomId = evt.room_id
|
|
841
1146
|
const TYPING_TTL_MS = 30_000
|
|
842
1147
|
const TYPING_REFRESH_MS = 25_000
|
|
843
1148
|
const safeTyping = (typing: boolean) =>
|
|
844
1149
|
client
|
|
845
|
-
.setTyping({
|
|
1150
|
+
.setTyping({
|
|
1151
|
+
roomId,
|
|
1152
|
+
asUserId: agent.userId,
|
|
1153
|
+
typing,
|
|
1154
|
+
timeoutMs: TYPING_TTL_MS,
|
|
1155
|
+
})
|
|
846
1156
|
.catch((err) => console.warn(`[matrix:${agent.name}] setTyping(${typing}) failed:`, err))
|
|
847
1157
|
const safePresence = (presence: 'online' | 'unavailable' | 'offline') =>
|
|
848
1158
|
client
|
|
@@ -857,15 +1167,23 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
857
1167
|
void safeTyping(true)
|
|
858
1168
|
}, TYPING_REFRESH_MS)
|
|
859
1169
|
|
|
1170
|
+
let turnError: unknown
|
|
1171
|
+
let stopReason: StopReason | undefined
|
|
860
1172
|
try {
|
|
861
|
-
const rawBody =
|
|
862
|
-
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
|
|
863
1181
|
|
|
864
1182
|
// Drain pending media for this sender+thread and prepend as ACP content blocks.
|
|
865
1183
|
const pendingItems = pendingMedia.drain(
|
|
866
|
-
|
|
867
|
-
inboundThreadRoot(
|
|
868
|
-
|
|
1184
|
+
roomId,
|
|
1185
|
+
input.event ? inboundThreadRoot(input.event) : undefined,
|
|
1186
|
+
input.event?.sender ?? '',
|
|
869
1187
|
)
|
|
870
1188
|
const { blocks, pathLines } = await buildMediaBlocks(pendingItems, {
|
|
871
1189
|
agent,
|
|
@@ -874,7 +1192,7 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
874
1192
|
onError: (item, err) => {
|
|
875
1193
|
console.warn(`[matrix:${agent.name}] media_failed for ${item.body}:`, err)
|
|
876
1194
|
void sendMediaError(
|
|
877
|
-
{ agent, roomId
|
|
1195
|
+
{ agent, roomId, threadRoot },
|
|
878
1196
|
err,
|
|
879
1197
|
`Could not process attachment: ${item.body}`,
|
|
880
1198
|
client,
|
|
@@ -883,12 +1201,13 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
883
1201
|
})
|
|
884
1202
|
|
|
885
1203
|
const fullPromptText = [promptText, ...pathLines].filter(Boolean).join('\n')
|
|
886
|
-
await agents.prompt(agent.name, {
|
|
1204
|
+
const promptResult = await agents.prompt(agent.name, {
|
|
887
1205
|
threadId: sessionKey,
|
|
888
|
-
channelId:
|
|
1206
|
+
channelId: roomId,
|
|
889
1207
|
contextThreadId: threadRoot,
|
|
890
1208
|
content: [...blocks, { type: 'text', text: fullPromptText }],
|
|
891
1209
|
})
|
|
1210
|
+
stopReason = promptResult.stopReason as StopReason
|
|
892
1211
|
// Drain: the prompt promise resolves on the stopReason response, but
|
|
893
1212
|
// trailing chunks may still arrive (see DRAIN_* above). Wait until the
|
|
894
1213
|
// buffer is quiet for DRAIN_QUIET_MS, re-arming on each new chunk.
|
|
@@ -910,13 +1229,15 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
910
1229
|
// last message was flushed mid-stream). An unchanged *empty* buffer
|
|
911
1230
|
// with nothing flushed yet means the stream hasn't started; keep
|
|
912
1231
|
// waiting up to drainMaxMs.
|
|
913
|
-
if (next === drained && (next.length > 0 || (flushedCounts.get(sessionId) ?? 0) > 0))
|
|
914
|
-
break
|
|
1232
|
+
if (next === drained && (next.length > 0 || (flushedCounts.get(sessionId) ?? 0) > 0)) break
|
|
915
1233
|
drained = next
|
|
916
1234
|
}
|
|
917
1235
|
// Flush the final assistant message — the one with no following messageId
|
|
918
1236
|
// change or out-of-band event to have triggered an earlier flush.
|
|
919
1237
|
flushBuffer(sessionId)
|
|
1238
|
+
} catch (err) {
|
|
1239
|
+
turnError = err
|
|
1240
|
+
throw err
|
|
920
1241
|
} finally {
|
|
921
1242
|
clearInterval(refresh)
|
|
922
1243
|
await safeTyping(false)
|
|
@@ -928,7 +1249,7 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
928
1249
|
const producedOutput = (flushedCounts.get(sessionId) ?? 0) > 0
|
|
929
1250
|
if (!producedOutput) {
|
|
930
1251
|
console.warn(
|
|
931
|
-
`[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}`,
|
|
932
1253
|
)
|
|
933
1254
|
}
|
|
934
1255
|
// Turn boundary for [[ZOD076]] and push notifications. Sent after the
|
|
@@ -937,15 +1258,39 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
937
1258
|
// has nothing in it yet.
|
|
938
1259
|
await client
|
|
939
1260
|
.sendCustomEvent({
|
|
940
|
-
roomId
|
|
1261
|
+
roomId,
|
|
941
1262
|
asUserId: agent.userId,
|
|
942
1263
|
eventType: 'dev.zooid.turn.end',
|
|
943
1264
|
content: toTurnEndBody(
|
|
944
|
-
{
|
|
1265
|
+
{
|
|
1266
|
+
agentId: agent.name,
|
|
1267
|
+
sessionId,
|
|
1268
|
+
producedOutput,
|
|
1269
|
+
lastMessage: lastFlushed.get(sessionId),
|
|
1270
|
+
},
|
|
945
1271
|
threadRoot,
|
|
946
1272
|
),
|
|
947
1273
|
})
|
|
948
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
|
+
}
|
|
949
1294
|
buffers.delete(sessionId)
|
|
950
1295
|
bufferMessageIds.delete(sessionId)
|
|
951
1296
|
flushedCounts.delete(sessionId)
|
|
@@ -954,6 +1299,255 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
954
1299
|
}
|
|
955
1300
|
}
|
|
956
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
|
+
|
|
957
1551
|
const syncLoops: SyncLoop[] | undefined =
|
|
958
1552
|
mode === 'client'
|
|
959
1553
|
? bindings.map(
|
|
@@ -970,6 +1564,7 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
970
1564
|
|
|
971
1565
|
return {
|
|
972
1566
|
app,
|
|
1567
|
+
taskActions,
|
|
973
1568
|
syncLoops,
|
|
974
1569
|
bootstrap: async (
|
|
975
1570
|
bootstrapOpts: {
|
|
@@ -1003,11 +1598,17 @@ export async function rebuildThreadState(
|
|
|
1003
1598
|
rootEventId: string,
|
|
1004
1599
|
bindings: AgentBinding[],
|
|
1005
1600
|
): Promise<ThreadState> {
|
|
1006
|
-
const state: ThreadState = {
|
|
1601
|
+
const state: ThreadState = {
|
|
1602
|
+
participants: [],
|
|
1603
|
+
rootMentions: [],
|
|
1604
|
+
callers: {},
|
|
1605
|
+
handoffs: {},
|
|
1606
|
+
}
|
|
1007
1607
|
// Impersonate an agent that's actually a member of this room (AS reads
|
|
1008
1608
|
// require room membership). Falling through to the first binding would
|
|
1009
1609
|
// 403 if that agent never joined the target room.
|
|
1010
|
-
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
|
|
1011
1612
|
if (!asUser) return state
|
|
1012
1613
|
|
|
1013
1614
|
const root = await client.fetchEvent(roomId, rootEventId, asUser)
|
|
@@ -1018,7 +1619,11 @@ export async function rebuildThreadState(
|
|
|
1018
1619
|
for (const a of bindings) {
|
|
1019
1620
|
if (!rootMentions.has(a.userId)) continue
|
|
1020
1621
|
if (!state.rootMentions.includes(a.name)) state.rootMentions.push(a.name)
|
|
1021
|
-
if (
|
|
1622
|
+
if (
|
|
1623
|
+
rootSenderAgent &&
|
|
1624
|
+
a.name !== rootSenderAgent.name &&
|
|
1625
|
+
!wouldCycleCallers(state.callers, a.name, rootSenderAgent.name)
|
|
1626
|
+
) {
|
|
1022
1627
|
state.callers[a.name] = rootSenderAgent.name
|
|
1023
1628
|
const arcs = (state.handoffs[a.name] ??= [])
|
|
1024
1629
|
if (!arcs.includes(rootEventId)) arcs.push(rootEventId)
|
|
@@ -1040,7 +1645,11 @@ export async function rebuildThreadState(
|
|
|
1040
1645
|
for (const a of bindings) {
|
|
1041
1646
|
if (!mentions.has(a.userId)) continue
|
|
1042
1647
|
if (!state.rootMentions.includes(a.name)) state.rootMentions.push(a.name)
|
|
1043
|
-
if (
|
|
1648
|
+
if (
|
|
1649
|
+
evSenderAgent &&
|
|
1650
|
+
a.name !== evSenderAgent.name &&
|
|
1651
|
+
!wouldCycleCallers(state.callers, a.name, evSenderAgent.name)
|
|
1652
|
+
) {
|
|
1044
1653
|
state.callers[a.name] = evSenderAgent.name
|
|
1045
1654
|
if (evId) {
|
|
1046
1655
|
const arcs = (state.handoffs[a.name] ??= [])
|