@zooid/transport-matrix 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/transport.ts CHANGED
@@ -1,10 +1,28 @@
1
1
  import { Hono } from 'hono'
2
2
  import { timingSafeEqual } from 'node:crypto'
3
- import type { AcpRegistry, ApprovalCorrelator, RegisteredApproval } from '@zooid/core'
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 { route, isMediaMsgtype, type AgentBinding, type ThreadState } from './router.js'
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
- PendingMediaStore,
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 { agents, approvals, client, bindings, hsToken, adminUserId, botUserId, mode = 'appservice' } = opts
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') +
310
- '</p>'
467
+ '<p>' + text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') + '</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 { type?: string; text?: string; data?: string; mimeType?: string }
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({ data: bytes, contentType: block.mimeType, filename, asUserId: ctx.agent.userId })
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'] = { rel_type: 'm.thread', event_id: ctx.threadRoot }
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
- agents.endSession(a.name, composeHandoffKey(threadRoot, arc))
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 { session_id?: string; reason?: string }
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(`[matrix] cancelSession(${ctx.agent.name}, ${content.session_id}) failed:`, err)
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 matches = route(evt, bindings, threadStates)
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
- const msgMentions = new Set(extractMentions(evt as never))
713
- const senderAgent = bindings.find((b) => b.userId === evt.sender)
714
- for (const a of bindings) {
715
- if (!msgMentions.has(a.userId)) continue
716
- if (!st.rootMentions.includes(a.name)) st.rootMentions.push(a.name)
717
- // Call edge: the (agent) sender is the caller of every agent it @mentions.
718
- if (senderAgent && a.name !== senderAgent.name) {
719
- st.callers[a.name] = senderAgent.name
720
- // [[ZOD071]] handoff arc: every agent→agent call opens a fresh
721
- // session for the callee, keyed by this call event. Recorded before
722
- // runTurn dispatch so sessionKeyFor resolves the just-minted arc.
723
- // Deduped: homeservers may redeliver a transaction.
724
- if (evt.event_id) {
725
- const arcs = (st.handoffs[a.name] ??= [])
726
- if (!arcs.includes(evt.event_id)) arcs.push(evt.event_id)
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
- void runTurn(a, evt)
734
- .then(() => {
735
- if (!promotedRoot) return
736
- let st = threadStates.get(promotedRoot)
737
- if (!st) {
738
- st = { participants: [], rootMentions: [], callers: {}, handoffs: {} }
739
- threadStates.set(promotedRoot, st)
740
- }
741
- if (st.participants.at(-1) !== a.name) st.participants.push(a.name)
742
- })
743
- .catch((err) => {
744
- console.error(`[matrix] runTurn failed for ${a.name}:`, err)
745
- const c = classify(err)
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 { events?: MatrixEvent[] }
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, evt: MatrixEvent): Promise<void> {
816
- if (!evt.room_id || !evt.event_id) return
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 sessionKey = sessionKeyFor(agent.name, threadRoot, threadStates.get(threadRoot))
826
- const sessionId = await agents.ensureSession(agent.name, sessionKey, evt.room_id, threadRoot)
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({ roomId, asUserId: agent.userId, typing, timeoutMs: TYPING_TTL_MS })
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 = evt.content?.body ?? ''
862
- const promptText = stripMention(rawBody, agent.userId)
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
- evt.room_id,
867
- inboundThreadRoot(evt),
868
- evt.sender ?? '',
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: evt.room_id!, threadRoot },
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: evt.room_id,
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 ${evt.room_id}`,
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: evt.room_id,
1261
+ roomId,
941
1262
  asUserId: agent.userId,
942
1263
  eventType: 'dev.zooid.turn.end',
943
1264
  content: toTurnEndBody(
944
- { agentId: agent.name, sessionId, producedOutput, lastMessage: lastFlushed.get(sessionId) },
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 = { participants: [], rootMentions: [], callers: {}, handoffs: {} }
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])?.userId
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 (rootSenderAgent && a.name !== rootSenderAgent.name) {
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 (evSenderAgent && a.name !== evSenderAgent.name) {
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] ??= [])