@zooid/transport-matrix 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -13,7 +13,10 @@ var MatrixClient = class {
13
13
  const r = await this.fetch(`${this.homeserver}/_matrix/client/v3/register`, {
14
14
  method: "POST",
15
15
  headers: { Authorization: `Bearer ${this.asToken}` },
16
- body: JSON.stringify({ type: "m.login.application_service", username: localpart2 })
16
+ body: JSON.stringify({
17
+ type: "m.login.application_service",
18
+ username: localpart2
19
+ })
17
20
  });
18
21
  if (r.status === 200) return await r.json();
19
22
  if (r.status === 400) {
@@ -151,12 +154,15 @@ var MatrixClient = class {
151
154
  async sendMessage(input) {
152
155
  const content = { ...input.content };
153
156
  if (input.threadRoot) {
154
- content["m.relates_to"] = { rel_type: "m.thread", event_id: input.threadRoot };
157
+ content["m.relates_to"] = {
158
+ rel_type: "m.thread",
159
+ event_id: input.threadRoot
160
+ };
155
161
  }
156
- return this.sendEvent(input.roomId, input.asUserId, "m.room.message", content);
162
+ return this.sendEvent(input.roomId, input.asUserId, "m.room.message", content, input.txnId);
157
163
  }
158
164
  async sendCustomEvent(input) {
159
- return this.sendEvent(input.roomId, input.asUserId, input.eventType, input.content);
165
+ return this.sendEvent(input.roomId, input.asUserId, input.eventType, input.content, input.txnId);
160
166
  }
161
167
  async setTyping(input) {
162
168
  const url = `${this.homeserver}/_matrix/client/v3/rooms/${encodeURIComponent(input.roomId)}/typing/${encodeURIComponent(input.asUserId)}?user_id=${encodeURIComponent(input.asUserId)}`;
@@ -218,7 +224,7 @@ var MatrixClient = class {
218
224
  user_id: opts.asUserId
219
225
  });
220
226
  if (opts.from) params.set("from", opts.from);
221
- const url = `${this.homeserver}/_matrix/client/v1/rooms/${encodeURIComponent(opts.roomId)}/relations/${encodeURIComponent(opts.rootEventId)}/m.thread?${params.toString()}`;
227
+ const url = `${this.homeserver}/_matrix/client/v1/rooms/${encodeURIComponent(opts.roomId)}/relations/${encodeURIComponent(opts.rootEventId)}/m.thread/m.room.message?${params.toString()}`;
222
228
  const r = await this.fetch(url, {
223
229
  method: "GET",
224
230
  headers: { Authorization: `Bearer ${this.asToken}` }
@@ -281,15 +287,19 @@ var MatrixClient = class {
281
287
  const body = await r.json();
282
288
  return body.name ?? null;
283
289
  }
284
- async sendEvent(roomId, asUserId, eventType, content) {
285
- const txn = randomUUID();
290
+ async sendEvent(roomId, asUserId, eventType, content, txnId) {
291
+ const txn = txnId ?? randomUUID();
286
292
  const url = `${this.homeserver}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/${eventType}/${txn}?user_id=${encodeURIComponent(asUserId)}`;
287
293
  const r = await this.fetch(url, {
288
294
  method: "PUT",
289
295
  headers: { Authorization: `Bearer ${this.asToken}` },
290
296
  body: JSON.stringify(content)
291
297
  });
292
- if (!r.ok) throw new Error(`sendEvent(${eventType}) failed: ${r.status}`);
298
+ if (!r.ok) {
299
+ const err = new Error(`sendEvent(${eventType}) failed: ${r.status}`);
300
+ err.status = r.status;
301
+ throw err;
302
+ }
293
303
  return await r.json();
294
304
  }
295
305
  };
@@ -429,7 +439,7 @@ var MatrixContextProvider = class {
429
439
  };
430
440
  });
431
441
  }
432
- async getChannelInfo(channelId) {
442
+ async getRoomInfo(channelId) {
433
443
  const name = await this.opts.client.fetchRoomName(channelId, this.opts.asUserId);
434
444
  return {
435
445
  id: channelId,
@@ -437,6 +447,30 @@ var MatrixContextProvider = class {
437
447
  transport: "matrix"
438
448
  };
439
449
  }
450
+ async getRooms() {
451
+ const rooms = this.opts.rooms ?? [];
452
+ return Promise.all(
453
+ rooms.map(async (r) => {
454
+ const name = await this.opts.client.fetchRoomName(r.alias, this.opts.asUserId);
455
+ return { id: r.alias, name: name ?? r.alias, transport: "matrix" };
456
+ })
457
+ );
458
+ }
459
+ async sendMessage(input) {
460
+ const rooms = this.opts.rooms ?? [];
461
+ if (!rooms.some((r) => r.alias === input.room)) {
462
+ throw new Error(`not_in_room: this agent is not a member of ${input.room}`);
463
+ }
464
+ const { event_id } = await this.opts.client.sendMessage({
465
+ roomId: input.room,
466
+ asUserId: this.opts.asUserId,
467
+ // m.notice, not m.text: agent prose sends as m.notice so
468
+ // .m.rule.suppress_notices silences it server-side (ZNC025 §10).
469
+ content: { msgtype: "m.notice", body: input.text },
470
+ ...input.thread_id ? { threadRoot: input.thread_id } : {}
471
+ });
472
+ return { event_id, ...input.thread_id ? { thread_id: input.thread_id } : {} };
473
+ }
440
474
  };
441
475
 
442
476
  // src/registration.ts
@@ -515,7 +549,7 @@ function inboundThreadRoot(event) {
515
549
  const r = event.content?.["m.relates_to"];
516
550
  return r?.rel_type === "m.thread" && r.event_id ? r.event_id : void 0;
517
551
  }
518
- function route(event, agents, threadStates) {
552
+ function route(event, agents, threadStates, task) {
519
553
  if (event.type !== "m.room.message") return [];
520
554
  if (!event.content?.msgtype) return [];
521
555
  if (isMediaMsgtype(event.content.msgtype)) return [];
@@ -524,8 +558,25 @@ function route(event, agents, threadStates) {
524
558
  const threadRoot = inboundThreadRoot(event);
525
559
  const threadState = threadRoot ? threadStates?.get(threadRoot) : void 0;
526
560
  for (const a of agents) {
527
- if (event.sender === a.userId) continue;
528
561
  if (!a.rooms.some((r) => r.alias === event.room_id)) continue;
562
+ if (task?.isRoot) {
563
+ if (a.name === task.assignee) matches.push(a);
564
+ continue;
565
+ }
566
+ if (event.sender === a.userId) continue;
567
+ if (task) {
568
+ if (mentions.has(a.userId)) {
569
+ matches.push(a);
570
+ continue;
571
+ }
572
+ const senderAgent = agents.find((x) => x.userId === event.sender);
573
+ if (senderAgent) {
574
+ continue;
575
+ } else if (a.name === task.assignee) {
576
+ matches.push(a);
577
+ }
578
+ continue;
579
+ }
529
580
  if (a.trigger === "any") {
530
581
  matches.push(a);
531
582
  continue;
@@ -537,7 +588,7 @@ function route(event, agents, threadStates) {
537
588
  if (threadState) {
538
589
  const senderAgent = agents.find((x) => x.userId === event.sender);
539
590
  if (senderAgent) {
540
- if (threadState.callers[senderAgent.name] === a.name) matches.push(a);
591
+ if (isReturnRoute(event, a, agents, threadState)) matches.push(a);
541
592
  } else {
542
593
  const lastPoster = threadState.participants.at(-1);
543
594
  if (lastPoster) {
@@ -550,6 +601,22 @@ function route(event, agents, threadStates) {
550
601
  }
551
602
  return matches;
552
603
  }
604
+ function isReturnRoute(event, agent, agents, threadState) {
605
+ if (!threadState || agent.trigger !== "mention") return false;
606
+ const sender = agents.find((x) => x.userId === event.sender);
607
+ if (!sender || sender.name === agent.name) return false;
608
+ return threadState.callers[sender.name] === agent.name;
609
+ }
610
+ function wouldCycleCallers(callers, callee, caller) {
611
+ const seen = /* @__PURE__ */ new Set();
612
+ let cursor = caller;
613
+ while (cursor !== void 0) {
614
+ if (cursor === callee || seen.has(cursor)) return true;
615
+ seen.add(cursor);
616
+ cursor = callers[cursor];
617
+ }
618
+ return false;
619
+ }
553
620
 
554
621
  // src/space-provisioner.ts
555
622
  async function ensureWorkforceSpace(opts) {
@@ -745,6 +812,7 @@ function buildUserPowerLevels(asUserId, admins, agents, roomAlias) {
745
812
  // src/transport.ts
746
813
  import { Hono } from "hono";
747
814
  import { timingSafeEqual } from "crypto";
815
+ import { THREAD_RESULT_FIELD, THREAD_START_FIELD as THREAD_START_FIELD2 } from "@zooid/core";
748
816
 
749
817
  // src/session-keys.ts
750
818
  var HANDOFF_KEY_SEP = "|";
@@ -1061,8 +1129,282 @@ var SyncLoop = class {
1061
1129
  }
1062
1130
  };
1063
1131
 
1132
+ // src/transport.ts
1133
+ import { NO_PENDING_INPUT } from "@zooid/core";
1134
+
1135
+ // src/task-registry.ts
1136
+ import { randomUUID as randomUUID2 } from "crypto";
1137
+ var MAX_OPEN_TASKS_PER_ROOM = 5;
1138
+ var TaskRegistry = class {
1139
+ constructor(opts = {}) {
1140
+ this.opts = opts;
1141
+ this.runIdValue = this.opts.runId ?? randomUUID2();
1142
+ }
1143
+ opts;
1144
+ tasks = /* @__PURE__ */ new Map();
1145
+ byRoot = /* @__PURE__ */ new Map();
1146
+ generations = /* @__PURE__ */ new Map();
1147
+ runIdValue;
1148
+ get max() {
1149
+ return this.opts.maxOpenPerRoom ?? MAX_OPEN_TASKS_PER_ROOM;
1150
+ }
1151
+ get runId() {
1152
+ return this.runIdValue;
1153
+ }
1154
+ save() {
1155
+ if (!this.opts.journal) return;
1156
+ const rows = [...this.tasks.values()].map((r) => ({ ...r, runId: r.runId ?? this.runId }));
1157
+ const max = this.opts.maxClosedRecords ?? 500;
1158
+ const open = rows.filter((r) => r.phase !== "closed");
1159
+ const closed = rows.filter((r) => r.phase === "closed").sort((a, b) => (b.closedAt ?? "").localeCompare(a.closedAt ?? "")).slice(0, max);
1160
+ this.opts.journal.save([...open, ...closed]);
1161
+ }
1162
+ openCount(roomId) {
1163
+ return [...this.tasks.values()].filter((t) => t.roomId === roomId && t.phase !== "closed").length;
1164
+ }
1165
+ reserve(input) {
1166
+ if (this.openCount(input.roomId) >= this.max) return;
1167
+ const id = this.opts.newId?.() ?? randomUUID2();
1168
+ const rec = {
1169
+ taskId: id,
1170
+ attemptId: id,
1171
+ phase: "reserved",
1172
+ runId: this.runId,
1173
+ ...input
1174
+ };
1175
+ this.tasks.set(id, rec);
1176
+ this.save();
1177
+ return rec;
1178
+ }
1179
+ activate(taskId, threadRoot) {
1180
+ const r = this.tasks.get(taskId);
1181
+ if (!r) return;
1182
+ r.phase = "open";
1183
+ r.threadRoot = threadRoot;
1184
+ this.byRoot.set(threadRoot, taskId);
1185
+ this.save();
1186
+ }
1187
+ abandon(taskId) {
1188
+ this.tasks.delete(taskId);
1189
+ this.save();
1190
+ }
1191
+ markUncertain(taskId) {
1192
+ const r = this.tasks.get(taskId);
1193
+ if (r?.phase === "reserved") r.phase = "uncertain";
1194
+ this.save();
1195
+ }
1196
+ adopt(attemptId, threadRoot) {
1197
+ const r = this.tasks.get(attemptId);
1198
+ if (!r) return;
1199
+ if (r.phase === "closed" || r.threadRoot && r.threadRoot !== threadRoot) return r;
1200
+ this.activate(r.taskId, threadRoot);
1201
+ return r;
1202
+ }
1203
+ taskForRoot(threadRoot) {
1204
+ const id = this.byRoot.get(threadRoot);
1205
+ return id ? this.tasks.get(id) : void 0;
1206
+ }
1207
+ openTaskFor(agent, root) {
1208
+ const r = this.taskForRoot(root);
1209
+ return r?.phase === "open" && r.assignee === agent ? r : void 0;
1210
+ }
1211
+ recordSummary(id, summary) {
1212
+ const r = this.tasks.get(id);
1213
+ if (!r || r.summary !== void 0) return "already_recorded";
1214
+ r.summary = summary;
1215
+ this.save();
1216
+ return "recorded";
1217
+ }
1218
+ clearSummary(id) {
1219
+ const r = this.tasks.get(id);
1220
+ if (r) r.summary = void 0;
1221
+ this.save();
1222
+ }
1223
+ close(id) {
1224
+ const r = this.tasks.get(id);
1225
+ if (!r || r.phase === "closed") return false;
1226
+ r.phase = "closed";
1227
+ r.closedAt = (/* @__PURE__ */ new Date()).toISOString();
1228
+ this.save();
1229
+ return true;
1230
+ }
1231
+ /** Reconcile records from a prior daemon run and retain closed roots for trust checks. */
1232
+ restore() {
1233
+ const rows = this.opts.journal?.load() ?? [];
1234
+ const interrupted = [];
1235
+ for (const row of rows) {
1236
+ const rec = { ...row };
1237
+ if (rec.phase !== "closed" && rec.runId !== this.runId) {
1238
+ rec.phase = "closed";
1239
+ rec.closedAt = (/* @__PURE__ */ new Date()).toISOString();
1240
+ interrupted.push(rec);
1241
+ }
1242
+ this.tasks.set(rec.taskId, rec);
1243
+ if (rec.threadRoot) this.byRoot.set(rec.threadRoot, rec.taskId);
1244
+ }
1245
+ this.save();
1246
+ return interrupted;
1247
+ }
1248
+ generationOf(agent, session) {
1249
+ return this.generations.get(`${agent}::${session}`) ?? 0;
1250
+ }
1251
+ bumpGeneration(agent, session) {
1252
+ const k = `${agent}::${session}`;
1253
+ this.generations.set(k, this.generationOf(agent, session) + 1);
1254
+ }
1255
+ };
1256
+
1257
+ // src/invocation-registry.ts
1258
+ import { randomUUID as randomUUID3 } from "crypto";
1259
+ var InvocationRegistry = class {
1260
+ constructor(opts = {}) {
1261
+ this.opts = opts;
1262
+ }
1263
+ opts;
1264
+ records = /* @__PURE__ */ new Map();
1265
+ byCallee = /* @__PURE__ */ new Map();
1266
+ byEvent = /* @__PURE__ */ new Map();
1267
+ open(input) {
1268
+ const record = { invocationId: this.opts.newId?.() ?? randomUUID3(), state: "outstanding", ...input };
1269
+ this.records.set(record.invocationId, record);
1270
+ return record;
1271
+ }
1272
+ attachCallEvent(id, eventId, sessionKey) {
1273
+ const r = this.records.get(id);
1274
+ if (!r) return;
1275
+ r.callEventId = eventId;
1276
+ r.calleeSessionKey = sessionKey;
1277
+ this.byEvent.set(eventId, id);
1278
+ this.byCallee.set(sessionKey, id);
1279
+ }
1280
+ get(id) {
1281
+ return this.records.get(id);
1282
+ }
1283
+ byCallEvent(eventId) {
1284
+ const id = this.byEvent.get(eventId);
1285
+ return id ? this.records.get(id) : void 0;
1286
+ }
1287
+ forCalleeSession(session) {
1288
+ const id = this.byCallee.get(session);
1289
+ return id ? this.records.get(id) : void 0;
1290
+ }
1291
+ outstandingFor(session) {
1292
+ return [...this.records.values()].filter((x) => x.state === "outstanding" && x.callerSessionKey === session);
1293
+ }
1294
+ outstandingForTask(taskId) {
1295
+ return [...this.records.values()].filter((x) => x.state === "outstanding" && x.taskId === taskId);
1296
+ }
1297
+ resolve(id) {
1298
+ const r = this.records.get(id);
1299
+ if (!r || r.state !== "outstanding") return;
1300
+ r.state = "returned";
1301
+ return r;
1302
+ }
1303
+ cancelForTask(taskId) {
1304
+ const records = this.outstandingForTask(taskId);
1305
+ for (const r of records) r.state = "cancelled";
1306
+ return records;
1307
+ }
1308
+ ancestorAgents(session) {
1309
+ const agents = [], seen = /* @__PURE__ */ new Set([session]);
1310
+ let cursor = session;
1311
+ for (; ; ) {
1312
+ const r = this.forCalleeSession(cursor);
1313
+ if (!r || r.state !== "outstanding") break;
1314
+ agents.push(r.callerAgent);
1315
+ if (seen.has(r.callerSessionKey)) break;
1316
+ seen.add(r.callerSessionKey);
1317
+ cursor = r.callerSessionKey;
1318
+ }
1319
+ return agents;
1320
+ }
1321
+ isOutstandingAncestor(session, agent) {
1322
+ return this.ancestorAgents(session).includes(agent);
1323
+ }
1324
+ };
1325
+
1326
+ // src/task-completion.ts
1327
+ function evaluateCompletion(input) {
1328
+ const prose = input.prose?.trim();
1329
+ const output = prose ? { type: "message", text: prose } : void 0;
1330
+ const finish = (completion) => ({
1331
+ decision: "finish",
1332
+ completion: { agent: input.agent, thread_id: input.threadId, ...completion }
1333
+ });
1334
+ if (input.error !== void 0)
1335
+ return finish({ status: "failed", error: input.error instanceof Error ? input.error.message : String(input.error), ...output ? { output } : {} });
1336
+ if (input.stopReason === "cancelled") return finish({ status: "cancelled", ...output ? { output } : {} });
1337
+ if (input.stopReason === "max_tokens" || input.stopReason === "max_turn_requests")
1338
+ return finish({ status: "partial", reason: input.stopReason, ...output ? { output } : {} });
1339
+ if (input.stopReason === "refusal") return finish({ status: "failed", reason: "refusal", ...output ? { output } : {} });
1340
+ if (input.awaitingHuman > 0) return { decision: "stay_open", reason: "awaiting_human" };
1341
+ if (input.outstanding > 0) return { decision: "stay_open", reason: "outstanding_handoff" };
1342
+ if (input.summary) return finish({ status: "complete", output: { type: "message", text: input.summary } });
1343
+ if (output) return finish({ status: "complete", output });
1344
+ return finish({ status: "failed", reason: "no_result", error: "No result produced" });
1345
+ }
1346
+
1347
+ // src/task-dispatch.ts
1348
+ import { THREAD_START_FIELD } from "@zooid/core";
1349
+ function checkDelegable(agentName, roomId, bindings) {
1350
+ const target = bindings.find((b) => b.name === agentName);
1351
+ if (!target)
1352
+ return {
1353
+ ok: false,
1354
+ reason: `unknown_agent: no agent named "${agentName}" is configured here`
1355
+ };
1356
+ if (!target.rooms.some((r) => r.alias === roomId))
1357
+ return {
1358
+ ok: false,
1359
+ reason: `not_in_room: "${agentName}" is not a member of this room`
1360
+ };
1361
+ return { ok: true };
1362
+ }
1363
+ function buildAssignmentContent(input) {
1364
+ const escaped = input.prompt.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1365
+ const body = `${input.assigneeUserId} ${input.prompt}`.trim();
1366
+ return {
1367
+ msgtype: "m.notice",
1368
+ body,
1369
+ format: "org.matrix.custom.html",
1370
+ formatted_body: `<a href="https://matrix.to/#/${encodeURIComponent(input.assigneeUserId)}">${input.assigneeUserId}</a> ${escaped}`,
1371
+ "m.mentions": { user_ids: [input.assigneeUserId] },
1372
+ [THREAD_START_FIELD]: input.start
1373
+ };
1374
+ }
1375
+ function renderCompletionPrompt(c) {
1376
+ return [
1377
+ `[task result] ${c.agent} \u2014 status: ${c.status} (thread ${c.thread_id})`,
1378
+ ...c.reason ? [`reason: ${c.reason}`] : [],
1379
+ ...c.error ? [`error: ${c.error}`] : [],
1380
+ ...c.output?.text ? ["", c.output.text] : []
1381
+ ].join("\n");
1382
+ }
1383
+ function renderInvocationReturn(c) {
1384
+ return [
1385
+ `[handoff result] ${c.agent} \u2014 status: ${c.status}`,
1386
+ ...c.reason ? [`reason: ${c.reason}`] : [],
1387
+ ...c.error ? [`error: ${c.error}`] : [],
1388
+ ...c.output?.text ? ["", c.output.text] : []
1389
+ ].join("\n");
1390
+ }
1391
+ function renderDelivery(notify) {
1392
+ return notify === "caller" ? "Each result returns to you as a new turn when that task completes. End your turn now \u2014 do not read the task thread to wait for it." : "No result returns to you. The task thread is the result surface; thread_id is for later reference, not something to wait on.";
1393
+ }
1394
+ function renderAssigneeEnvelope(input) {
1395
+ return [
1396
+ `[task] from ${input.parentAgent} \u2014 you are the assignee of this thread.`,
1397
+ "Call zooid_complete_task with a self-contained summary when you are done;",
1398
+ "ending your turn without one publishes your last message as the result.",
1399
+ "Sibling task threads are refused here \u2014 @mention an agent in this thread to hand off.",
1400
+ "",
1401
+ input.prompt
1402
+ ].join("\n");
1403
+ }
1404
+
1064
1405
  // src/transport.ts
1065
1406
  var STARTUP_GRACE_MS = 5e3;
1407
+ var RETURN_GRACE_MS = 9e4;
1066
1408
  async function buildMediaBlocks(items, opts) {
1067
1409
  const blocks = [];
1068
1410
  const pathLines = [];
@@ -1154,9 +1496,19 @@ function inboundThreadRoot2(evt) {
1154
1496
  return r?.rel_type === "m.thread" && r.event_id ? r.event_id : void 0;
1155
1497
  }
1156
1498
  function createMatrixTransport(opts) {
1157
- const { agents, approvals, client, bindings, hsToken, adminUserId, botUserId, mode = "appservice" } = opts;
1499
+ const {
1500
+ agents,
1501
+ approvals,
1502
+ client,
1503
+ bindings,
1504
+ hsToken,
1505
+ adminUserId,
1506
+ botUserId,
1507
+ mode = "appservice"
1508
+ } = opts;
1158
1509
  const drainQuietMs = opts.drainQuietMs ?? DRAIN_QUIET_MS;
1159
1510
  const drainMaxMs = opts.drainMaxMs ?? DRAIN_MAX_MS;
1511
+ const returnGraceMs = opts.returnGraceMs ?? RETURN_GRACE_MS;
1160
1512
  const mediaClient = opts.media;
1161
1513
  const writeAttachmentFn = opts.writeAttachmentFn ?? writeAttachment;
1162
1514
  const pendingMedia = new PendingMediaStore();
@@ -1171,6 +1523,70 @@ function createMatrixTransport(opts) {
1171
1523
  const bufferMessageIds = /* @__PURE__ */ new Map();
1172
1524
  const sendQueue = /* @__PURE__ */ new Map();
1173
1525
  const threadStates = /* @__PURE__ */ new Map();
1526
+ const taskRegistry = new TaskRegistry({ journal: opts.taskJournal, runId: opts.taskRunId });
1527
+ const interruptedTasks = taskRegistry.restore();
1528
+ const invocations = new InvocationRegistry();
1529
+ const pendingInput = opts.pendingInput ?? NO_PENDING_INPUT;
1530
+ const bindingFor = (name) => bindings.find((b) => b.name === name);
1531
+ const turnQueues = /* @__PURE__ */ new Map();
1532
+ const pendingReturns = /* @__PURE__ */ new Map();
1533
+ const returnKey = (agentName, threadRoot) => `${agentName}::${threadRoot}`;
1534
+ function stashReturn(sender, threadRoot, roomId, evt, targets) {
1535
+ const key = returnKey(sender.name, threadRoot);
1536
+ let pending = pendingReturns.get(key);
1537
+ if (!pending) {
1538
+ pending = { roomId, threadRoot, event: evt, texts: [], targets: /* @__PURE__ */ new Map() };
1539
+ pendingReturns.set(key, pending);
1540
+ }
1541
+ pending.event = evt;
1542
+ const body = evt.content?.body?.trim();
1543
+ if (body) pending.texts.push(body);
1544
+ for (const t of targets) pending.targets.set(t.name, t);
1545
+ if (pending.timer) clearTimeout(pending.timer);
1546
+ pending.timer = setTimeout(() => releaseReturn(key), returnGraceMs);
1547
+ pending.timer.unref?.();
1548
+ console.log(
1549
+ `[matrix] holding return ${sender.name} \u2192 ${[...pending.targets.keys()].join(",")} until turn end (thread=${threadRoot})`
1550
+ );
1551
+ }
1552
+ function releaseReturn(key) {
1553
+ const pending = pendingReturns.get(key);
1554
+ if (!pending) return;
1555
+ pendingReturns.delete(key);
1556
+ if (pending.timer) clearTimeout(pending.timer);
1557
+ const promptText = pending.texts.join("\n\n");
1558
+ for (const target of pending.targets.values()) {
1559
+ console.log(`[matrix] \u2192 ${target.name} (${target.userId}) [return]`);
1560
+ void enqueueTurn(target, {
1561
+ roomId: pending.roomId,
1562
+ threadRoot: pending.threadRoot,
1563
+ sessionKey: sessionKeyFor(
1564
+ target.name,
1565
+ pending.threadRoot,
1566
+ threadStates.get(pending.threadRoot)
1567
+ ),
1568
+ event: pending.event,
1569
+ ...promptText ? { promptText } : {}
1570
+ });
1571
+ }
1572
+ }
1573
+ function dropPendingReturn(threadRoot, agentName) {
1574
+ for (const [key, pending] of pendingReturns) {
1575
+ if (pending.threadRoot !== threadRoot) continue;
1576
+ if (!pending.targets.delete(agentName)) continue;
1577
+ if (pending.targets.size === 0) {
1578
+ if (pending.timer) clearTimeout(pending.timer);
1579
+ pendingReturns.delete(key);
1580
+ }
1581
+ }
1582
+ }
1583
+ function dropThreadReturns(threadRoot) {
1584
+ for (const [key, pending] of pendingReturns) {
1585
+ if (pending.threadRoot !== threadRoot) continue;
1586
+ if (pending.timer) clearTimeout(pending.timer);
1587
+ pendingReturns.delete(key);
1588
+ }
1589
+ }
1174
1590
  const cutoffTs = mode === "client" ? Number.NEGATIVE_INFINITY : Date.now() - STARTUP_GRACE_MS;
1175
1591
  const seenEventIds = /* @__PURE__ */ new Set();
1176
1592
  const flushedCounts = /* @__PURE__ */ new Map();
@@ -1204,14 +1620,21 @@ function createMatrixTransport(opts) {
1204
1620
  lastFlushed.set(sessionId, text);
1205
1621
  flushedCounts.set(sessionId, (flushedCounts.get(sessionId) ?? 0) + 1);
1206
1622
  const content = buildTextContent(text);
1623
+ const pendingInvocations = registerOutgoingHandoffs(sessionId, text);
1207
1624
  const tail = (sendQueue.get(sessionId) ?? Promise.resolve()).then(async () => {
1208
1625
  try {
1209
- await client.sendMessage({
1626
+ const { event_id } = await client.sendMessage({
1210
1627
  roomId: ctx.roomId,
1211
1628
  asUserId: ctx.agent.userId,
1212
1629
  content,
1213
1630
  threadRoot: ctx.threadRoot
1214
1631
  });
1632
+ for (const invocation of pendingInvocations)
1633
+ invocations.attachCallEvent(
1634
+ invocation.invocationId,
1635
+ event_id,
1636
+ composeHandoffKey(ctx.threadRoot, event_id)
1637
+ );
1215
1638
  } catch (err) {
1216
1639
  console.warn(`[matrix:${ctx.agent.name}] sendMessage flush failed:`, err);
1217
1640
  }
@@ -1219,6 +1642,30 @@ function createMatrixTransport(opts) {
1219
1642
  sendQueue.set(sessionId, tail);
1220
1643
  return true;
1221
1644
  };
1645
+ function registerOutgoingHandoffs(sessionId, text) {
1646
+ const ctx = sessions.get(sessionId);
1647
+ if (!ctx) return [];
1648
+ const task = taskRegistry.taskForRoot(ctx.threadRoot);
1649
+ if (!task || task.phase !== "open") return [];
1650
+ const sessionKey = sessionKeyFor(ctx.agent.name, ctx.threadRoot, threadStates.get(ctx.threadRoot));
1651
+ const opened = [];
1652
+ for (const userId of extractMentions({ content: { body: text } })) {
1653
+ const callee = bindings.find((binding) => binding.userId === userId);
1654
+ if (!callee || callee.name === ctx.agent.name) continue;
1655
+ if (invocations.isOutstandingAncestor(sessionKey, callee.name)) {
1656
+ void client.sendCustomEvent({
1657
+ roomId: ctx.roomId,
1658
+ asUserId: ctx.agent.userId,
1659
+ eventType: "dev.zooid.error",
1660
+ content: { body: `\u26A0 [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 } }
1661
+ });
1662
+ continue;
1663
+ }
1664
+ opened.push(invocations.open({ taskId: task.taskId, callerAgent: ctx.agent.name, callerSessionKey: sessionKey, calleeAgent: callee.name }));
1665
+ taskRegistry.clearSummary(task.taskId);
1666
+ }
1667
+ return opened;
1668
+ }
1222
1669
  agents.onEvent = async (name, event) => {
1223
1670
  const ctx = sessions.get(event.sessionId);
1224
1671
  if (!ctx) {
@@ -1234,8 +1681,7 @@ function createMatrixTransport(opts) {
1234
1681
  if (block.type === "text" && typeof block.text === "string") {
1235
1682
  const prevMessageId = bufferMessageIds.get(event.sessionId);
1236
1683
  const messageChanged = event.messageId !== void 0 && prevMessageId !== void 0 && event.messageId !== prevMessageId;
1237
- if (event.messageId !== void 0)
1238
- bufferMessageIds.set(event.sessionId, event.messageId);
1684
+ if (event.messageId !== void 0) bufferMessageIds.set(event.sessionId, event.messageId);
1239
1685
  if (messageChanged) flushBuffer(event.sessionId);
1240
1686
  const current = buffers.get(event.sessionId) ?? "";
1241
1687
  const needsBreak = current.length > 0 && block.text === "";
@@ -1247,7 +1693,12 @@ function createMatrixTransport(opts) {
1247
1693
  const bytes = Buffer.from(block.data, "base64");
1248
1694
  const ext = (block.mimeType.split("/")[1] ?? "png").replace(/[^a-z0-9]/gi, "");
1249
1695
  const filename = `image.${ext}`;
1250
- void mediaClient.upload({ data: bytes, contentType: block.mimeType, filename, asUserId: ctx2.agent.userId }).then(
1696
+ void mediaClient.upload({
1697
+ data: bytes,
1698
+ contentType: block.mimeType,
1699
+ filename,
1700
+ asUserId: ctx2.agent.userId
1701
+ }).then(
1251
1702
  ({ content_uri }) => client.sendMessage({
1252
1703
  roomId: ctx2.roomId,
1253
1704
  asUserId: ctx2.agent.userId,
@@ -1303,7 +1754,10 @@ function createMatrixTransport(opts) {
1303
1754
  tool_call_id: handle.toolCallId,
1304
1755
  options: handle.options
1305
1756
  };
1306
- content["m.relates_to"] = { rel_type: "m.thread", event_id: ctx.threadRoot };
1757
+ content["m.relates_to"] = {
1758
+ rel_type: "m.thread",
1759
+ event_id: ctx.threadRoot
1760
+ };
1307
1761
  if (handle.toolKind !== void 0) content.tool_kind = handle.toolKind;
1308
1762
  if (handle.toolTitle !== void 0) content.tool_title = handle.toolTitle;
1309
1763
  if (handle.toolInput !== void 0) content.tool_input = handle.toolInput;
@@ -1314,6 +1768,51 @@ function createMatrixTransport(opts) {
1314
1768
  content
1315
1769
  });
1316
1770
  });
1771
+ function reportTurnFailure(agent, input, err) {
1772
+ console.error(`[matrix] runTurn failed for ${agent.name}:`, err);
1773
+ const c = classify(err);
1774
+ const body = toErrorBody(
1775
+ {
1776
+ kind: "error",
1777
+ agentId: agent.name,
1778
+ sessionId: null,
1779
+ turnId: null,
1780
+ code: c.code,
1781
+ message: err instanceof Error ? err.message : String(err),
1782
+ detail: err instanceof Error && err.stack ? err.stack.slice(0, 2e3) : void 0,
1783
+ transient: c.transient,
1784
+ acp_error: c.acp_error
1785
+ },
1786
+ input.threadRoot
1787
+ );
1788
+ void client.sendCustomEvent({
1789
+ roomId: input.roomId,
1790
+ asUserId: agent.userId,
1791
+ eventType: "dev.zooid.error",
1792
+ content: body
1793
+ }).catch((e) => console.warn(`[matrix:${agent.name}] dev.zooid.error send failed:`, e));
1794
+ }
1795
+ function enqueueTurn(agent, input) {
1796
+ const key = `${agent.name}::${input.sessionKey}`;
1797
+ const chained = (turnQueues.get(key) ?? Promise.resolve()).then(() => runTurn(agent, input)).then(() => {
1798
+ let st = threadStates.get(input.threadRoot);
1799
+ if (!st) {
1800
+ st = {
1801
+ participants: [],
1802
+ rootMentions: [],
1803
+ callers: {},
1804
+ handoffs: {}
1805
+ };
1806
+ threadStates.set(input.threadRoot, st);
1807
+ }
1808
+ if (st.participants.at(-1) !== agent.name) st.participants.push(agent.name);
1809
+ }).catch((err) => reportTurnFailure(agent, input, err));
1810
+ turnQueues.set(key, chained);
1811
+ void chained.finally(() => {
1812
+ if (turnQueues.get(key) === chained) turnQueues.delete(key);
1813
+ });
1814
+ return chained;
1815
+ }
1317
1816
  async function handleInboundEvent(evt) {
1318
1817
  if (evt.event_id) {
1319
1818
  if (seenEventIds.has(evt.event_id)) {
@@ -1352,6 +1851,7 @@ function createMatrixTransport(opts) {
1352
1851
  return;
1353
1852
  }
1354
1853
  console.log(`[matrix] inbound dev.zooid.session_reset in ${evt.room_id} thread=${threadRoot}`);
1854
+ dropThreadReturns(threadRoot);
1355
1855
  if (!threadStates.has(threadRoot) && evt.room_id) {
1356
1856
  try {
1357
1857
  threadStates.set(
@@ -1365,8 +1865,11 @@ function createMatrixTransport(opts) {
1365
1865
  const st = threadStates.get(threadRoot);
1366
1866
  for (const a of bindings) {
1367
1867
  agents.endSession(a.name, threadRoot);
1868
+ taskRegistry.bumpGeneration(a.name, threadRoot);
1368
1869
  for (const arc of st?.handoffs[a.name] ?? []) {
1369
- agents.endSession(a.name, composeHandoffKey(threadRoot, arc));
1870
+ const key = composeHandoffKey(threadRoot, arc);
1871
+ agents.endSession(a.name, key);
1872
+ taskRegistry.bumpGeneration(a.name, key);
1370
1873
  }
1371
1874
  }
1372
1875
  return;
@@ -1390,6 +1893,15 @@ function createMatrixTransport(opts) {
1390
1893
  console.error(`[matrix] cancelSession(${t.agent}, ${t.sessionId}) failed:`, err);
1391
1894
  });
1392
1895
  }
1896
+ const task = taskRegistry.taskForRoot(threadRoot);
1897
+ if (task?.phase === "open" && !targets.some((t) => t.agent === task.assignee)) {
1898
+ const assignee = bindingFor(task.assignee);
1899
+ if (assignee)
1900
+ await finishTask(task, {
1901
+ agent: assignee,
1902
+ completion: { agent: assignee.name, thread_id: threadRoot, status: "cancelled" }
1903
+ });
1904
+ }
1393
1905
  return;
1394
1906
  }
1395
1907
  if (!content.session_id) {
@@ -1404,7 +1916,10 @@ function createMatrixTransport(opts) {
1404
1916
  `[matrix] interrupt session=${content.session_id} agent=${ctx.agent.name}` + (content.reason ? ` reason=${content.reason}` : "")
1405
1917
  );
1406
1918
  await agents.cancelSession(ctx.agent.name, content.session_id).catch((err) => {
1407
- console.error(`[matrix] cancelSession(${ctx.agent.name}, ${content.session_id}) failed:`, err);
1919
+ console.error(
1920
+ `[matrix] cancelSession(${ctx.agent.name}, ${content.session_id}) failed:`,
1921
+ err
1922
+ );
1408
1923
  });
1409
1924
  return;
1410
1925
  }
@@ -1412,15 +1927,20 @@ function createMatrixTransport(opts) {
1412
1927
  const content = evt.content ?? {};
1413
1928
  if (!content.session_id || !content.approval_id || !content.decision) return;
1414
1929
  const decision = content.option_id ? { decision: content.decision, optionId: content.option_id } : { decision: content.decision };
1415
- const ok = approvals.resolve(
1416
- content.session_id,
1417
- content.approval_id,
1418
- decision
1419
- );
1930
+ const ok = approvals.resolve(content.session_id, content.approval_id, decision);
1420
1931
  if (!ok) console.warn(`[matrix] unknown approval ${content.approval_id}`);
1421
1932
  return;
1422
1933
  }
1423
1934
  logInbound(evt);
1935
+ if (evt.type === "dev.zooid.turn.end") {
1936
+ const agentId = evt.content?.agent_id;
1937
+ const endedRoot = inboundThreadRoot2(evt);
1938
+ const senderAgent = bindings.find((binding) => binding.userId === evt.sender);
1939
+ if (agentId && endedRoot && senderAgent?.name === agentId) {
1940
+ releaseReturn(returnKey(agentId, endedRoot));
1941
+ }
1942
+ return;
1943
+ }
1424
1944
  if (evt.type === "m.room.message" && isMediaMsgtype(evt.content?.msgtype) && evt.room_id && evt.event_id && evt.sender && evt.content?.url && !bindings.some((b) => b.userId === evt.sender)) {
1425
1945
  pendingMedia.add(evt.room_id, inboundThreadRoot2(evt), {
1426
1946
  eventId: evt.event_id,
@@ -1446,7 +1966,32 @@ function createMatrixTransport(opts) {
1446
1966
  console.warn(`[matrix] failed to rebuild threadState for ${inboundRel}:`, err);
1447
1967
  }
1448
1968
  }
1449
- const matches = route(evt, bindings, threadStates);
1969
+ const startField = evt.content?.[THREAD_START_FIELD2];
1970
+ if (startField?.attempt_id && !inboundRel && evt.event_id)
1971
+ taskRegistry.adopt(startField.attempt_id, evt.event_id);
1972
+ if (evt.content?.[THREAD_RESULT_FIELD] !== void 0) return;
1973
+ const taskRec = promotedRoot ? taskRegistry.taskForRoot(promotedRoot) : void 0;
1974
+ const taskCtx = taskRec && taskRec.phase !== "reserved" ? {
1975
+ assignee: taskRec.assignee,
1976
+ isRoot: !inboundRel && evt.event_id === taskRec.threadRoot
1977
+ } : void 0;
1978
+ let matches = route(evt, bindings, threadStates, taskCtx);
1979
+ if (taskCtx && !taskCtx.isRoot && evt.event_id && bindings.some((b) => b.userId === evt.sender)) {
1980
+ const invocation = invocations.byCallEvent(evt.event_id);
1981
+ matches = invocation ? matches.filter((match) => match.name === invocation.calleeAgent) : [];
1982
+ }
1983
+ if (evt.type === "m.room.message" && promotedRoot && evt.room_id) {
1984
+ const senderBinding = bindings.find((b) => b.userId === evt.sender);
1985
+ if (senderBinding) {
1986
+ const st = threadStates.get(promotedRoot);
1987
+ const held = matches.filter((m) => isReturnRoute(evt, m, bindings, st));
1988
+ if (held.length > 0) {
1989
+ matches = matches.filter((m) => !held.includes(m));
1990
+ stashReturn(senderBinding, promotedRoot, evt.room_id, evt, held);
1991
+ }
1992
+ }
1993
+ for (const m of matches) dropPendingReturn(promotedRoot, m.name);
1994
+ }
1450
1995
  const senderIsBot = bindings.some((b) => b.userId === evt.sender);
1451
1996
  if (evt.type === "m.room.message" && matches.length === 0 && !senderIsBot) {
1452
1997
  console.warn(
@@ -1459,55 +2004,35 @@ function createMatrixTransport(opts) {
1459
2004
  st = { participants: [], rootMentions: [], callers: {}, handoffs: {} };
1460
2005
  threadStates.set(promotedRoot, st);
1461
2006
  }
1462
- const msgMentions = new Set(extractMentions(evt));
1463
- const senderAgent = bindings.find((b) => b.userId === evt.sender);
1464
- for (const a of bindings) {
1465
- if (!msgMentions.has(a.userId)) continue;
1466
- if (!st.rootMentions.includes(a.name)) st.rootMentions.push(a.name);
1467
- if (senderAgent && a.name !== senderAgent.name) {
1468
- st.callers[a.name] = senderAgent.name;
1469
- if (evt.event_id) {
1470
- const arcs = st.handoffs[a.name] ??= [];
1471
- if (!arcs.includes(evt.event_id)) arcs.push(evt.event_id);
2007
+ if (taskCtx?.isRoot) {
2008
+ if (!st.rootMentions.includes(taskRec.assignee)) st.rootMentions.push(taskRec.assignee);
2009
+ } else {
2010
+ const msgMentions = new Set(extractMentions(evt));
2011
+ const senderAgent = bindings.find((b) => b.userId === evt.sender);
2012
+ for (const a of bindings) {
2013
+ if (!msgMentions.has(a.userId)) continue;
2014
+ if (!st.rootMentions.includes(a.name)) st.rootMentions.push(a.name);
2015
+ if (senderAgent && a.name !== senderAgent.name && !wouldCycleCallers(st.callers, a.name, senderAgent.name)) {
2016
+ st.callers[a.name] = senderAgent.name;
2017
+ if (evt.event_id) {
2018
+ const arcs = st.handoffs[a.name] ??= [];
2019
+ if (!arcs.includes(evt.event_id)) arcs.push(evt.event_id);
2020
+ }
1472
2021
  }
1473
2022
  }
1474
2023
  }
1475
2024
  }
1476
2025
  for (const a of matches) {
1477
2026
  console.log(`[matrix] \u2192 ${a.name} (${a.userId})`);
1478
- void runTurn(a, evt).then(() => {
1479
- if (!promotedRoot) return;
1480
- let st = threadStates.get(promotedRoot);
1481
- if (!st) {
1482
- st = { participants: [], rootMentions: [], callers: {}, handoffs: {} };
1483
- threadStates.set(promotedRoot, st);
1484
- }
1485
- if (st.participants.at(-1) !== a.name) st.participants.push(a.name);
1486
- }).catch((err) => {
1487
- console.error(`[matrix] runTurn failed for ${a.name}:`, err);
1488
- const c = classify(err);
1489
- const threadRoot = inboundThreadRoot2(evt) ?? evt.event_id;
1490
- if (!threadRoot || !evt.room_id) return;
1491
- const body = toErrorBody(
1492
- {
1493
- kind: "error",
1494
- agentId: a.name,
1495
- sessionId: null,
1496
- turnId: null,
1497
- code: c.code,
1498
- message: err instanceof Error ? err.message : String(err),
1499
- detail: err instanceof Error && err.stack ? err.stack.slice(0, 2e3) : void 0,
1500
- transient: c.transient,
1501
- acp_error: c.acp_error
1502
- },
1503
- threadRoot
1504
- );
1505
- void client.sendCustomEvent({
1506
- roomId: evt.room_id,
1507
- asUserId: a.userId,
1508
- eventType: "dev.zooid.error",
1509
- content: body
1510
- }).catch((e) => console.warn(`[matrix:${a.name}] dev.zooid.error send failed:`, e));
2027
+ if (!promotedRoot || !evt.room_id) continue;
2028
+ const sessionKey = sessionKeyFor(a.name, promotedRoot, threadStates.get(promotedRoot));
2029
+ const taskEnvelope = taskCtx?.isRoot && a.name === taskRec.assignee ? { parentAgent: taskRec.parent.agent } : void 0;
2030
+ void enqueueTurn(a, {
2031
+ roomId: evt.room_id,
2032
+ threadRoot: promotedRoot,
2033
+ sessionKey,
2034
+ event: evt,
2035
+ ...taskEnvelope ? { taskEnvelope } : {}
1511
2036
  });
1512
2037
  }
1513
2038
  }
@@ -1548,13 +2073,10 @@ function createMatrixTransport(opts) {
1548
2073
  return c.json({});
1549
2074
  });
1550
2075
  app.get("/healthz", (c) => c.text("ok"));
1551
- async function runTurn(agent, evt) {
1552
- if (!evt.room_id || !evt.event_id) return;
1553
- const inbound = inboundThreadRoot2(evt);
1554
- const threadRoot = inbound ?? evt.event_id;
1555
- const sessionKey = sessionKeyFor(agent.name, threadRoot, threadStates.get(threadRoot));
1556
- const sessionId = await agents.ensureSession(agent.name, sessionKey, evt.room_id, threadRoot);
1557
- sessions.set(sessionId, { agent, roomId: evt.room_id, threadRoot });
2076
+ async function runTurn(agent, input) {
2077
+ const { roomId, threadRoot, sessionKey } = input;
2078
+ const sessionId = await agents.ensureSession(agent.name, sessionKey, roomId, threadRoot);
2079
+ sessions.set(sessionId, { agent, roomId, threadRoot });
1558
2080
  buffers.set(sessionId, "");
1559
2081
  bufferMessageIds.delete(sessionId);
1560
2082
  flushedCounts.set(sessionId, 0);
@@ -1563,10 +2085,14 @@ function createMatrixTransport(opts) {
1563
2085
  pendingCommands.delete(sessionId);
1564
2086
  void agents.onEvent?.(agent.name, stashedCommands);
1565
2087
  }
1566
- const roomId = evt.room_id;
1567
2088
  const TYPING_TTL_MS = 3e4;
1568
2089
  const TYPING_REFRESH_MS = 25e3;
1569
- const safeTyping = (typing) => client.setTyping({ roomId, asUserId: agent.userId, typing, timeoutMs: TYPING_TTL_MS }).catch((err) => console.warn(`[matrix:${agent.name}] setTyping(${typing}) failed:`, err));
2090
+ const safeTyping = (typing) => client.setTyping({
2091
+ roomId,
2092
+ asUserId: agent.userId,
2093
+ typing,
2094
+ timeoutMs: TYPING_TTL_MS
2095
+ }).catch((err) => console.warn(`[matrix:${agent.name}] setTyping(${typing}) failed:`, err));
1570
2096
  const safePresence = (presence) => client.setPresence({ asUserId: agent.userId, presence }).catch(
1571
2097
  (err) => console.warn(`[matrix:${agent.name}] setPresence(${presence}) failed:`, err)
1572
2098
  );
@@ -1575,13 +2101,19 @@ function createMatrixTransport(opts) {
1575
2101
  const refresh = setInterval(() => {
1576
2102
  void safeTyping(true);
1577
2103
  }, TYPING_REFRESH_MS);
2104
+ let turnError;
2105
+ let stopReason;
1578
2106
  try {
1579
- const rawBody = evt.content?.body ?? "";
1580
- const promptText = stripMention(rawBody, agent.userId);
2107
+ const rawBody = input.event?.content?.body ?? "";
2108
+ const strippedPromptText = input.promptText ?? stripMention(rawBody, agent.userId);
2109
+ const promptText = input.taskEnvelope ? renderAssigneeEnvelope({
2110
+ parentAgent: input.taskEnvelope.parentAgent,
2111
+ prompt: strippedPromptText
2112
+ }) : strippedPromptText;
1581
2113
  const pendingItems = pendingMedia.drain(
1582
- evt.room_id,
1583
- inboundThreadRoot2(evt),
1584
- evt.sender ?? ""
2114
+ roomId,
2115
+ input.event ? inboundThreadRoot2(input.event) : void 0,
2116
+ input.event?.sender ?? ""
1585
2117
  );
1586
2118
  const { blocks, pathLines } = await buildMediaBlocks(pendingItems, {
1587
2119
  agent,
@@ -1590,7 +2122,7 @@ function createMatrixTransport(opts) {
1590
2122
  onError: (item, err) => {
1591
2123
  console.warn(`[matrix:${agent.name}] media_failed for ${item.body}:`, err);
1592
2124
  void sendMediaError(
1593
- { agent, roomId: evt.room_id, threadRoot },
2125
+ { agent, roomId, threadRoot },
1594
2126
  err,
1595
2127
  `Could not process attachment: ${item.body}`,
1596
2128
  client
@@ -1598,22 +2130,25 @@ function createMatrixTransport(opts) {
1598
2130
  }
1599
2131
  });
1600
2132
  const fullPromptText = [promptText, ...pathLines].filter(Boolean).join("\n");
1601
- await agents.prompt(agent.name, {
2133
+ const promptResult = await agents.prompt(agent.name, {
1602
2134
  threadId: sessionKey,
1603
- channelId: evt.room_id,
2135
+ channelId: roomId,
1604
2136
  contextThreadId: threadRoot,
1605
2137
  content: [...blocks, { type: "text", text: fullPromptText }]
1606
2138
  });
2139
+ stopReason = promptResult.stopReason;
1607
2140
  const drainStart = Date.now();
1608
2141
  let drained = buffers.get(sessionId) ?? "";
1609
2142
  while (drainQuietMs > 0 && Date.now() - drainStart < drainMaxMs) {
1610
2143
  await delay(drainQuietMs);
1611
2144
  const next = buffers.get(sessionId) ?? "";
1612
- if (next === drained && (next.length > 0 || (flushedCounts.get(sessionId) ?? 0) > 0))
1613
- break;
2145
+ if (next === drained && (next.length > 0 || (flushedCounts.get(sessionId) ?? 0) > 0)) break;
1614
2146
  drained = next;
1615
2147
  }
1616
2148
  flushBuffer(sessionId);
2149
+ } catch (err) {
2150
+ turnError = err;
2151
+ throw err;
1617
2152
  } finally {
1618
2153
  clearInterval(refresh);
1619
2154
  await safeTyping(false);
@@ -1622,18 +2157,42 @@ function createMatrixTransport(opts) {
1622
2157
  const producedOutput = (flushedCounts.get(sessionId) ?? 0) > 0;
1623
2158
  if (!producedOutput) {
1624
2159
  console.warn(
1625
- `[matrix:${agent.name}] turn finished with empty buffer (session=${sessionId}); nothing sent to ${evt.room_id}`
2160
+ `[matrix:${agent.name}] turn finished with empty buffer (session=${sessionId}); nothing sent to ${roomId}`
1626
2161
  );
1627
2162
  }
1628
2163
  await client.sendCustomEvent({
1629
- roomId: evt.room_id,
2164
+ roomId,
1630
2165
  asUserId: agent.userId,
1631
2166
  eventType: "dev.zooid.turn.end",
1632
2167
  content: toTurnEndBody(
1633
- { agentId: agent.name, sessionId, producedOutput, lastMessage: lastFlushed.get(sessionId) },
2168
+ {
2169
+ agentId: agent.name,
2170
+ sessionId,
2171
+ producedOutput,
2172
+ lastMessage: lastFlushed.get(sessionId)
2173
+ },
1634
2174
  threadRoot
1635
2175
  )
1636
2176
  }).catch((e) => console.warn(`[matrix:${agent.name}] turn.end send failed:`, e));
2177
+ const task = taskRegistry.taskForRoot(threadRoot);
2178
+ const invocation = invocations.forCalleeSession(sessionKey);
2179
+ const isAssignee = task?.phase === "open" && task.assignee === agent.name && task.threadRoot === sessionKey;
2180
+ if (task?.phase === "open" && (isAssignee || invocation?.state === "outstanding")) {
2181
+ const decision = evaluateCompletion({
2182
+ agent: agent.name,
2183
+ threadId: isAssignee ? threadRoot : invocation?.calleeSessionKey ?? sessionKey,
2184
+ stopReason,
2185
+ error: turnError,
2186
+ summary: isAssignee ? task.summary : void 0,
2187
+ prose: lastFlushed.get(sessionId),
2188
+ outstanding: invocations.outstandingFor(sessionKey).length,
2189
+ awaitingHuman: pendingInput.countFor(sessionKey)
2190
+ });
2191
+ if (decision.decision === "finish") {
2192
+ if (isAssignee) await finishTask(task, { agent, completion: decision.completion });
2193
+ else if (invocation) returnInvocation(invocation, decision.completion, task);
2194
+ }
2195
+ }
1637
2196
  buffers.delete(sessionId);
1638
2197
  bufferMessageIds.delete(sessionId);
1639
2198
  flushedCounts.delete(sessionId);
@@ -1641,6 +2200,237 @@ function createMatrixTransport(opts) {
1641
2200
  sendQueue.delete(sessionId);
1642
2201
  }
1643
2202
  }
2203
+ async function finishTask(task, ctx) {
2204
+ const threadId = task.threadRoot;
2205
+ const completion = ctx.completion;
2206
+ if (!taskRegistry.close(task.taskId)) return;
2207
+ const cancelled = invocations.cancelForTask(task.taskId);
2208
+ pendingInput.cancelFor([threadId, ...cancelled.map((i) => i.calleeSessionKey).filter((x) => Boolean(x))]);
2209
+ await client.sendCustomEvent({
2210
+ roomId: task.roomId,
2211
+ asUserId: ctx.agent.userId,
2212
+ eventType: THREAD_RESULT_FIELD,
2213
+ content: {
2214
+ ...completion,
2215
+ "m.relates_to": { rel_type: "m.thread", event_id: threadId }
2216
+ }
2217
+ });
2218
+ if (task.summary && task.summary !== completion.output?.text)
2219
+ await client.sendMessage({
2220
+ roomId: task.roomId,
2221
+ asUserId: ctx.agent.userId,
2222
+ threadRoot: threadId,
2223
+ content: buildTextContent(task.summary)
2224
+ });
2225
+ if (task.notify === "none") return;
2226
+ const parent = bindingFor(task.parent.agent);
2227
+ await client.sendMessage({
2228
+ roomId: task.roomId,
2229
+ asUserId: ctx.agent.userId,
2230
+ threadRoot: task.parent.threadRoot,
2231
+ content: {
2232
+ msgtype: "m.notice",
2233
+ body: renderCompletionPrompt(completion),
2234
+ [THREAD_RESULT_FIELD]: completion
2235
+ }
2236
+ });
2237
+ if (!parent || taskRegistry.generationOf(task.parent.agent, task.parent.sessionKey) !== task.parent.generation)
2238
+ return;
2239
+ void enqueueTurn(parent, {
2240
+ roomId: task.roomId,
2241
+ threadRoot: task.parent.threadRoot,
2242
+ sessionKey: task.parent.sessionKey,
2243
+ promptText: renderCompletionPrompt(completion)
2244
+ });
2245
+ }
2246
+ function returnInvocation(invocation, completion, task) {
2247
+ const resolved = invocations.resolve(invocation.invocationId);
2248
+ if (!resolved || task.phase !== "open") return;
2249
+ const caller = bindingFor(resolved.callerAgent);
2250
+ if (!caller || !task.threadRoot) return;
2251
+ void enqueueTurn(caller, {
2252
+ roomId: task.roomId,
2253
+ threadRoot: task.threadRoot,
2254
+ sessionKey: resolved.callerSessionKey,
2255
+ promptText: renderInvocationReturn(completion)
2256
+ });
2257
+ }
2258
+ const taskActions = {
2259
+ async startTasks(caller, input) {
2260
+ const notify = input.notify ?? "caller";
2261
+ const results = new Array(input.tasks.length);
2262
+ const callerBinding = bindingFor(caller.agentName);
2263
+ const enclosing = taskRegistry.taskForRoot(caller.threadRoot);
2264
+ const admitted = [];
2265
+ for (const [index, spec] of input.tasks.entries()) {
2266
+ if (!callerBinding) {
2267
+ results[index] = {
2268
+ agent: spec.agent,
2269
+ status: "refused",
2270
+ reason: "unknown_caller"
2271
+ };
2272
+ continue;
2273
+ }
2274
+ if (enclosing) {
2275
+ results[index] = {
2276
+ agent: spec.agent,
2277
+ status: "refused",
2278
+ reason: "depth_limit: this thread is itself a delegated task. Do the work here, or @mention another agent in this thread to hand off."
2279
+ };
2280
+ continue;
2281
+ }
2282
+ const admission = checkDelegable(spec.agent, caller.channelId, bindings);
2283
+ if (!admission.ok) {
2284
+ results[index] = {
2285
+ agent: spec.agent,
2286
+ status: "refused",
2287
+ reason: admission.reason
2288
+ };
2289
+ continue;
2290
+ }
2291
+ const rec = taskRegistry.reserve({
2292
+ roomId: caller.channelId,
2293
+ assignee: spec.agent,
2294
+ notify,
2295
+ parent: {
2296
+ agent: caller.agentName,
2297
+ threadRoot: caller.threadRoot,
2298
+ sessionKey: caller.sessionKey,
2299
+ generation: taskRegistry.generationOf(caller.agentName, caller.sessionKey)
2300
+ }
2301
+ });
2302
+ if (!rec) {
2303
+ results[index] = {
2304
+ agent: spec.agent,
2305
+ status: "refused",
2306
+ reason: `at_capacity: ${MAX_OPEN_TASKS_PER_ROOM} tasks are already open in this room. Wait for one to finish.`
2307
+ };
2308
+ continue;
2309
+ }
2310
+ admitted.push({ index, spec, rec });
2311
+ }
2312
+ await Promise.all(
2313
+ admitted.map(async ({ index, spec, rec }) => {
2314
+ const assignee = bindingFor(spec.agent);
2315
+ const content = buildAssignmentContent({
2316
+ assigneeUserId: assignee.userId,
2317
+ prompt: spec.prompt,
2318
+ start: {
2319
+ version: 1,
2320
+ assignee: spec.agent,
2321
+ attempt_id: rec.attemptId,
2322
+ parent: {
2323
+ agent: rec.parent.agent,
2324
+ thread_root: rec.parent.threadRoot,
2325
+ session_key: rec.parent.sessionKey
2326
+ },
2327
+ notify
2328
+ }
2329
+ });
2330
+ const post = () => client.sendMessage({
2331
+ roomId: caller.channelId,
2332
+ asUserId: callerBinding.userId,
2333
+ content,
2334
+ txnId: rec.attemptId
2335
+ });
2336
+ try {
2337
+ const { event_id } = await post();
2338
+ taskRegistry.activate(rec.taskId, event_id);
2339
+ results[index] = {
2340
+ agent: spec.agent,
2341
+ status: "started",
2342
+ thread_id: event_id
2343
+ };
2344
+ } catch {
2345
+ try {
2346
+ const { event_id } = await post();
2347
+ taskRegistry.activate(rec.taskId, event_id);
2348
+ results[index] = {
2349
+ agent: spec.agent,
2350
+ status: "started",
2351
+ thread_id: event_id
2352
+ };
2353
+ } catch (second) {
2354
+ const status = second.status;
2355
+ if (status !== void 0 && status >= 400 && status < 500 && status !== 429) {
2356
+ taskRegistry.abandon(rec.taskId);
2357
+ results[index] = {
2358
+ agent: spec.agent,
2359
+ status: "failed",
2360
+ reason: `post_failed: ${String(second.message)}`
2361
+ };
2362
+ } else {
2363
+ taskRegistry.markUncertain(rec.taskId);
2364
+ results[index] = {
2365
+ agent: spec.agent,
2366
+ status: "failed",
2367
+ reason: `post_uncertain: ${String(second.message)}`,
2368
+ attempt_id: rec.attemptId
2369
+ };
2370
+ }
2371
+ }
2372
+ }
2373
+ })
2374
+ );
2375
+ return { results, notify, delivery: renderDelivery(notify) };
2376
+ },
2377
+ async completeTask(caller, input) {
2378
+ const summary = input.summary.trim();
2379
+ if (!summary) return { status: "refused", reason: "summary must be non-empty" };
2380
+ const rec = taskRegistry.openTaskFor(caller.agentName, caller.threadRoot);
2381
+ if (!rec || rec.threadRoot !== caller.sessionKey)
2382
+ return {
2383
+ status: "refused",
2384
+ reason: "no_open_task: this session is not the assignee of an open task"
2385
+ };
2386
+ if (invocations.outstandingFor(caller.sessionKey).length)
2387
+ return { status: "refused", reason: "outstanding_handoff: wait for delegated work to return" };
2388
+ return { status: taskRegistry.recordSummary(rec.taskId, summary) };
2389
+ },
2390
+ async describeRole(caller) {
2391
+ const enclosing = taskRegistry.taskForRoot(caller.threadRoot);
2392
+ const openTask = taskRegistry.openTaskFor(caller.agentName, caller.threadRoot);
2393
+ return {
2394
+ is_task_assignee: openTask !== void 0 && openTask.threadRoot === caller.sessionKey,
2395
+ can_start_task_threads: enclosing === void 0
2396
+ };
2397
+ }
2398
+ };
2399
+ queueMicrotask(() => {
2400
+ for (const task of interruptedTasks) {
2401
+ if (!task.threadRoot) continue;
2402
+ const assignee = bindingFor(task.assignee);
2403
+ if (!assignee) continue;
2404
+ const completion = {
2405
+ agent: task.assignee,
2406
+ thread_id: task.threadRoot,
2407
+ status: "cancelled",
2408
+ reason: "interrupted_by_restart"
2409
+ };
2410
+ void client.sendCustomEvent({
2411
+ roomId: task.roomId,
2412
+ asUserId: assignee.userId,
2413
+ eventType: THREAD_RESULT_FIELD,
2414
+ content: { ...completion, "m.relates_to": { rel_type: "m.thread", event_id: task.threadRoot } }
2415
+ });
2416
+ if (task.notify !== "none") {
2417
+ const parent = bindingFor(task.parent.agent);
2418
+ if (parent && taskRegistry.generationOf(task.parent.agent, task.parent.sessionKey) === task.parent.generation) {
2419
+ void client.sendMessage({
2420
+ roomId: task.roomId,
2421
+ asUserId: assignee.userId,
2422
+ threadRoot: task.parent.threadRoot,
2423
+ content: {
2424
+ msgtype: "m.notice",
2425
+ body: renderCompletionPrompt(completion),
2426
+ [THREAD_RESULT_FIELD]: completion
2427
+ }
2428
+ });
2429
+ void enqueueTurn(parent, { roomId: task.roomId, threadRoot: task.parent.threadRoot, sessionKey: task.parent.sessionKey, promptText: renderCompletionPrompt(completion) });
2430
+ }
2431
+ }
2432
+ }
2433
+ });
1644
2434
  const syncLoops = mode === "client" ? bindings.map(
1645
2435
  (b) => new SyncLoop({
1646
2436
  client,
@@ -1652,6 +2442,7 @@ function createMatrixTransport(opts) {
1652
2442
  ) : void 0;
1653
2443
  return {
1654
2444
  app,
2445
+ taskActions,
1655
2446
  syncLoops,
1656
2447
  bootstrap: async (bootstrapOpts = {}) => {
1657
2448
  await pool.bootstrap({ adminUserId, ...bootstrapOpts });
@@ -1667,7 +2458,12 @@ function createMatrixTransport(opts) {
1667
2458
  };
1668
2459
  }
1669
2460
  async function rebuildThreadState(client, roomId, rootEventId, bindings) {
1670
- const state = { participants: [], rootMentions: [], callers: {}, handoffs: {} };
2461
+ const state = {
2462
+ participants: [],
2463
+ rootMentions: [],
2464
+ callers: {},
2465
+ handoffs: {}
2466
+ };
1671
2467
  const asUser = (bindings.find((b) => b.rooms.some((r) => r.alias === roomId)) ?? bindings[0])?.userId;
1672
2468
  if (!asUser) return state;
1673
2469
  const root = await client.fetchEvent(roomId, rootEventId, asUser);
@@ -1678,7 +2474,7 @@ async function rebuildThreadState(client, roomId, rootEventId, bindings) {
1678
2474
  for (const a of bindings) {
1679
2475
  if (!rootMentions.has(a.userId)) continue;
1680
2476
  if (!state.rootMentions.includes(a.name)) state.rootMentions.push(a.name);
1681
- if (rootSenderAgent && a.name !== rootSenderAgent.name) {
2477
+ if (rootSenderAgent && a.name !== rootSenderAgent.name && !wouldCycleCallers(state.callers, a.name, rootSenderAgent.name)) {
1682
2478
  state.callers[a.name] = rootSenderAgent.name;
1683
2479
  const arcs = state.handoffs[a.name] ??= [];
1684
2480
  if (!arcs.includes(rootEventId)) arcs.push(rootEventId);
@@ -1698,7 +2494,7 @@ async function rebuildThreadState(client, roomId, rootEventId, bindings) {
1698
2494
  for (const a of bindings) {
1699
2495
  if (!mentions.has(a.userId)) continue;
1700
2496
  if (!state.rootMentions.includes(a.name)) state.rootMentions.push(a.name);
1701
- if (evSenderAgent && a.name !== evSenderAgent.name) {
2497
+ if (evSenderAgent && a.name !== evSenderAgent.name && !wouldCycleCallers(state.callers, a.name, evSenderAgent.name)) {
1702
2498
  state.callers[a.name] = evSenderAgent.name;
1703
2499
  if (evId) {
1704
2500
  const arcs = state.handoffs[a.name] ??= [];
@@ -1767,9 +2563,11 @@ export {
1767
2563
  AGENT_KEY_RE,
1768
2564
  BotPool,
1769
2565
  INLINE_IMAGE_MIMES,
2566
+ InvocationRegistry,
1770
2567
  MAX_DOWNLOAD_BYTES,
1771
2568
  MAX_INLINE_IMAGE_BYTES,
1772
2569
  MAX_MEDIA_PER_TURN,
2570
+ MAX_OPEN_TASKS_PER_ROOM,
1773
2571
  MEDIA_MSGTYPES,
1774
2572
  MatrixClient,
1775
2573
  MatrixContextProvider,
@@ -1777,17 +2575,23 @@ export {
1777
2575
  PendingMediaStore,
1778
2576
  SLUG_RE,
1779
2577
  SyncLoop,
2578
+ TaskRegistry,
1780
2579
  agentMxid,
2580
+ buildAssignmentContent,
1781
2581
  buildWorkforceRoster,
2582
+ checkDelegable,
1782
2583
  createMatrixTransport,
1783
2584
  ensureDefaultChannel,
1784
2585
  ensureWorkforceSpace,
2586
+ evaluateCompletion,
1785
2587
  extractMentions,
1786
2588
  isMediaMsgtype,
1787
2589
  isValidAgentKey,
1788
2590
  isValidWorkstation,
1789
2591
  parseMxcUri,
1790
2592
  publishWorkforce,
2593
+ renderCompletionPrompt,
2594
+ renderInvocationReturn,
1791
2595
  renderRegistration,
1792
2596
  route,
1793
2597
  serverNameFromMxid,