@zooid/transport-matrix 0.12.0 → 0.14.0

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