@zooid/transport-matrix 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.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
  };
@@ -331,7 +341,8 @@ var MatrixContextProvider = class {
331
341
  const threads = [];
332
342
  for (const ev of chunk) {
333
343
  if (ev.type !== "m.room.message") continue;
334
- if (ev.content?.msgtype !== "m.text" || typeof ev.content.body !== "string") continue;
344
+ if (ev.content?.msgtype !== "m.text" && ev.content?.msgtype !== "m.notice" || typeof ev.content.body !== "string")
345
+ continue;
335
346
  const relatesTo = ev.content["m.relates_to"];
336
347
  if (relatesTo?.rel_type === "m.thread") continue;
337
348
  const agent = this.opts.agentBots.get(ev.sender);
@@ -405,7 +416,7 @@ var MatrixContextProvider = class {
405
416
  ...threadId !== void 0 ? { thread_id: threadId } : {}
406
417
  };
407
418
  }
408
- if (msgtype !== "m.text" || typeof body !== "string") return null;
419
+ if (msgtype !== "m.text" && msgtype !== "m.notice" || typeof body !== "string") return null;
409
420
  return {
410
421
  id: ev.event_id,
411
422
  sender: ev.sender,
@@ -428,7 +439,7 @@ var MatrixContextProvider = class {
428
439
  };
429
440
  });
430
441
  }
431
- async getChannelInfo(channelId) {
442
+ async getRoomInfo(channelId) {
432
443
  const name = await this.opts.client.fetchRoomName(channelId, this.opts.asUserId);
433
444
  return {
434
445
  id: channelId,
@@ -436,6 +447,30 @@ var MatrixContextProvider = class {
436
447
  transport: "matrix"
437
448
  };
438
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
+ }
439
474
  };
440
475
 
441
476
  // src/registration.ts
@@ -514,7 +549,7 @@ function inboundThreadRoot(event) {
514
549
  const r = event.content?.["m.relates_to"];
515
550
  return r?.rel_type === "m.thread" && r.event_id ? r.event_id : void 0;
516
551
  }
517
- function route(event, agents, threadStates) {
552
+ function route(event, agents, threadStates, task) {
518
553
  if (event.type !== "m.room.message") return [];
519
554
  if (!event.content?.msgtype) return [];
520
555
  if (isMediaMsgtype(event.content.msgtype)) return [];
@@ -523,8 +558,25 @@ function route(event, agents, threadStates) {
523
558
  const threadRoot = inboundThreadRoot(event);
524
559
  const threadState = threadRoot ? threadStates?.get(threadRoot) : void 0;
525
560
  for (const a of agents) {
526
- if (event.sender === a.userId) continue;
527
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
+ }
528
580
  if (a.trigger === "any") {
529
581
  matches.push(a);
530
582
  continue;
@@ -536,7 +588,7 @@ function route(event, agents, threadStates) {
536
588
  if (threadState) {
537
589
  const senderAgent = agents.find((x) => x.userId === event.sender);
538
590
  if (senderAgent) {
539
- if (threadState.callers[senderAgent.name] === a.name) matches.push(a);
591
+ if (isReturnRoute(event, a, agents, threadState)) matches.push(a);
540
592
  } else {
541
593
  const lastPoster = threadState.participants.at(-1);
542
594
  if (lastPoster) {
@@ -549,6 +601,22 @@ function route(event, agents, threadStates) {
549
601
  }
550
602
  return matches;
551
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
+ }
552
620
 
553
621
  // src/space-provisioner.ts
554
622
  async function ensureWorkforceSpace(opts) {
@@ -744,6 +812,7 @@ function buildUserPowerLevels(asUserId, admins, agents, roomAlias) {
744
812
  // src/transport.ts
745
813
  import { Hono } from "hono";
746
814
  import { timingSafeEqual } from "crypto";
815
+ import { THREAD_RESULT_FIELD, THREAD_START_FIELD as THREAD_START_FIELD2 } from "@zooid/core";
747
816
 
748
817
  // src/session-keys.ts
749
818
  var HANDOFF_KEY_SEP = "|";
@@ -821,7 +890,11 @@ var RECOVERY_URLS = {
821
890
  function toErrorBody(evt, threadRoot) {
822
891
  const msg = evt.message.slice(0, 250);
823
892
  const out = {
824
- msgtype: "m.notice",
893
+ // No msgtype: dev.zooid.error is not m.room.message, so the field is
894
+ // meaningless here — it was a vestige of copying the message-body shape.
895
+ // Its presence used to force careful push-rule `before` positioning
896
+ // (ZNC025 §10); that positioning is kept regardless, since it also
897
+ // protects rules for event types that never carried the field.
825
898
  body: `\u26A0 [${evt.code}] ${msg}`,
826
899
  code: evt.code,
827
900
  message: msg,
@@ -836,6 +909,25 @@ function toErrorBody(evt, threadRoot) {
836
909
  if (recovery) out.recovery = recovery;
837
910
  return out;
838
911
  }
912
+ var PREVIEW_MAX = 140;
913
+ function toTurnEndBody(evt, threadRoot) {
914
+ const preview = evt.lastMessage?.trim().replace(/\s+/g, " ");
915
+ return {
916
+ // `body` stays the turn-boundary summary: it is what a generic Matrix
917
+ // client renders for this event, and the prose is already its own message
918
+ // in the timeline. The preview below exists only for the push, which
919
+ // cannot see that message — agent prose is `m.notice`, deliberately
920
+ // silenced by `.m.rule.suppress_notices` so a chatty turn doesn't fire one
921
+ // push per chunk ([[ZNC025]] §10). Without it the only notification the
922
+ // user gets says an agent finished and nothing about what it said.
923
+ body: evt.producedOutput ? `${evt.agentId} finished` : `${evt.agentId} finished without output`,
924
+ ...preview ? { last_message: preview.slice(0, PREVIEW_MAX) } : {},
925
+ agent_id: evt.agentId,
926
+ session_id: evt.sessionId,
927
+ produced_output: evt.producedOutput,
928
+ "m.relates_to": { rel_type: "m.thread", event_id: threadRoot }
929
+ };
930
+ }
839
931
 
840
932
  // src/transport.ts
841
933
  import { classify } from "@zooid/acp-client";
@@ -1037,8 +1129,282 @@ var SyncLoop = class {
1037
1129
  }
1038
1130
  };
1039
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
+
1040
1405
  // src/transport.ts
1041
1406
  var STARTUP_GRACE_MS = 5e3;
1407
+ var RETURN_GRACE_MS = 9e4;
1042
1408
  async function buildMediaBlocks(items, opts) {
1043
1409
  const blocks = [];
1044
1410
  const pathLines = [];
@@ -1130,9 +1496,19 @@ function inboundThreadRoot2(evt) {
1130
1496
  return r?.rel_type === "m.thread" && r.event_id ? r.event_id : void 0;
1131
1497
  }
1132
1498
  function createMatrixTransport(opts) {
1133
- 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;
1134
1509
  const drainQuietMs = opts.drainQuietMs ?? DRAIN_QUIET_MS;
1135
1510
  const drainMaxMs = opts.drainMaxMs ?? DRAIN_MAX_MS;
1511
+ const returnGraceMs = opts.returnGraceMs ?? RETURN_GRACE_MS;
1136
1512
  const mediaClient = opts.media;
1137
1513
  const writeAttachmentFn = opts.writeAttachmentFn ?? writeAttachment;
1138
1514
  const pendingMedia = new PendingMediaStore();
@@ -1147,13 +1523,81 @@ function createMatrixTransport(opts) {
1147
1523
  const bufferMessageIds = /* @__PURE__ */ new Map();
1148
1524
  const sendQueue = /* @__PURE__ */ new Map();
1149
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
+ }
1150
1590
  const cutoffTs = mode === "client" ? Number.NEGATIVE_INFINITY : Date.now() - STARTUP_GRACE_MS;
1151
1591
  const seenEventIds = /* @__PURE__ */ new Set();
1152
1592
  const flushedCounts = /* @__PURE__ */ new Map();
1153
1593
  const pendingCommands = /* @__PURE__ */ new Map();
1154
1594
  const buildTextContent = (text) => {
1155
1595
  const content = {
1156
- msgtype: "m.text",
1596
+ // m.notice, not m.text: .m.rule.suppress_notices silences the
1597
+ // chunk-storm of agent prose server-side (ZNC025 §10) instead of every
1598
+ // client having to filter it. dev.zooid.error carries the same tweak
1599
+ // for the same reason.
1600
+ msgtype: "m.notice",
1157
1601
  body: text
1158
1602
  };
1159
1603
  const html = toMatrixHtml(text);
@@ -1167,21 +1611,30 @@ function createMatrixTransport(opts) {
1167
1611
  }
1168
1612
  return content;
1169
1613
  };
1614
+ const lastFlushed = /* @__PURE__ */ new Map();
1170
1615
  const flushBuffer = (sessionId) => {
1171
1616
  const ctx = sessions.get(sessionId);
1172
1617
  const text = buffers.get(sessionId) ?? "";
1173
1618
  if (!ctx || text.length === 0) return false;
1174
1619
  buffers.set(sessionId, "");
1620
+ lastFlushed.set(sessionId, text);
1175
1621
  flushedCounts.set(sessionId, (flushedCounts.get(sessionId) ?? 0) + 1);
1176
1622
  const content = buildTextContent(text);
1623
+ const pendingInvocations = registerOutgoingHandoffs(sessionId, text);
1177
1624
  const tail = (sendQueue.get(sessionId) ?? Promise.resolve()).then(async () => {
1178
1625
  try {
1179
- await client.sendMessage({
1626
+ const { event_id } = await client.sendMessage({
1180
1627
  roomId: ctx.roomId,
1181
1628
  asUserId: ctx.agent.userId,
1182
1629
  content,
1183
1630
  threadRoot: ctx.threadRoot
1184
1631
  });
1632
+ for (const invocation of pendingInvocations)
1633
+ invocations.attachCallEvent(
1634
+ invocation.invocationId,
1635
+ event_id,
1636
+ composeHandoffKey(ctx.threadRoot, event_id)
1637
+ );
1185
1638
  } catch (err) {
1186
1639
  console.warn(`[matrix:${ctx.agent.name}] sendMessage flush failed:`, err);
1187
1640
  }
@@ -1189,6 +1642,30 @@ function createMatrixTransport(opts) {
1189
1642
  sendQueue.set(sessionId, tail);
1190
1643
  return true;
1191
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
+ }
1192
1669
  agents.onEvent = async (name, event) => {
1193
1670
  const ctx = sessions.get(event.sessionId);
1194
1671
  if (!ctx) {
@@ -1204,8 +1681,7 @@ function createMatrixTransport(opts) {
1204
1681
  if (block.type === "text" && typeof block.text === "string") {
1205
1682
  const prevMessageId = bufferMessageIds.get(event.sessionId);
1206
1683
  const messageChanged = event.messageId !== void 0 && prevMessageId !== void 0 && event.messageId !== prevMessageId;
1207
- if (event.messageId !== void 0)
1208
- bufferMessageIds.set(event.sessionId, event.messageId);
1684
+ if (event.messageId !== void 0) bufferMessageIds.set(event.sessionId, event.messageId);
1209
1685
  if (messageChanged) flushBuffer(event.sessionId);
1210
1686
  const current = buffers.get(event.sessionId) ?? "";
1211
1687
  const needsBreak = current.length > 0 && block.text === "";
@@ -1217,7 +1693,12 @@ function createMatrixTransport(opts) {
1217
1693
  const bytes = Buffer.from(block.data, "base64");
1218
1694
  const ext = (block.mimeType.split("/")[1] ?? "png").replace(/[^a-z0-9]/gi, "");
1219
1695
  const filename = `image.${ext}`;
1220
- 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(
1221
1702
  ({ content_uri }) => client.sendMessage({
1222
1703
  roomId: ctx2.roomId,
1223
1704
  asUserId: ctx2.agent.userId,
@@ -1273,7 +1754,10 @@ function createMatrixTransport(opts) {
1273
1754
  tool_call_id: handle.toolCallId,
1274
1755
  options: handle.options
1275
1756
  };
1276
- 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
+ };
1277
1761
  if (handle.toolKind !== void 0) content.tool_kind = handle.toolKind;
1278
1762
  if (handle.toolTitle !== void 0) content.tool_title = handle.toolTitle;
1279
1763
  if (handle.toolInput !== void 0) content.tool_input = handle.toolInput;
@@ -1284,6 +1768,51 @@ function createMatrixTransport(opts) {
1284
1768
  content
1285
1769
  });
1286
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
+ }
1287
1816
  async function handleInboundEvent(evt) {
1288
1817
  if (evt.event_id) {
1289
1818
  if (seenEventIds.has(evt.event_id)) {
@@ -1322,6 +1851,7 @@ function createMatrixTransport(opts) {
1322
1851
  return;
1323
1852
  }
1324
1853
  console.log(`[matrix] inbound dev.zooid.session_reset in ${evt.room_id} thread=${threadRoot}`);
1854
+ dropThreadReturns(threadRoot);
1325
1855
  if (!threadStates.has(threadRoot) && evt.room_id) {
1326
1856
  try {
1327
1857
  threadStates.set(
@@ -1335,8 +1865,11 @@ function createMatrixTransport(opts) {
1335
1865
  const st = threadStates.get(threadRoot);
1336
1866
  for (const a of bindings) {
1337
1867
  agents.endSession(a.name, threadRoot);
1868
+ taskRegistry.bumpGeneration(a.name, threadRoot);
1338
1869
  for (const arc of st?.handoffs[a.name] ?? []) {
1339
- 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);
1340
1873
  }
1341
1874
  }
1342
1875
  return;
@@ -1360,6 +1893,15 @@ function createMatrixTransport(opts) {
1360
1893
  console.error(`[matrix] cancelSession(${t.agent}, ${t.sessionId}) failed:`, err);
1361
1894
  });
1362
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
+ }
1363
1905
  return;
1364
1906
  }
1365
1907
  if (!content.session_id) {
@@ -1374,7 +1916,10 @@ function createMatrixTransport(opts) {
1374
1916
  `[matrix] interrupt session=${content.session_id} agent=${ctx.agent.name}` + (content.reason ? ` reason=${content.reason}` : "")
1375
1917
  );
1376
1918
  await agents.cancelSession(ctx.agent.name, content.session_id).catch((err) => {
1377
- 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
+ );
1378
1923
  });
1379
1924
  return;
1380
1925
  }
@@ -1382,15 +1927,20 @@ function createMatrixTransport(opts) {
1382
1927
  const content = evt.content ?? {};
1383
1928
  if (!content.session_id || !content.approval_id || !content.decision) return;
1384
1929
  const decision = content.option_id ? { decision: content.decision, optionId: content.option_id } : { decision: content.decision };
1385
- const ok = approvals.resolve(
1386
- content.session_id,
1387
- content.approval_id,
1388
- decision
1389
- );
1930
+ const ok = approvals.resolve(content.session_id, content.approval_id, decision);
1390
1931
  if (!ok) console.warn(`[matrix] unknown approval ${content.approval_id}`);
1391
1932
  return;
1392
1933
  }
1393
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
+ }
1394
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)) {
1395
1945
  pendingMedia.add(evt.room_id, inboundThreadRoot2(evt), {
1396
1946
  eventId: evt.event_id,
@@ -1416,7 +1966,32 @@ function createMatrixTransport(opts) {
1416
1966
  console.warn(`[matrix] failed to rebuild threadState for ${inboundRel}:`, err);
1417
1967
  }
1418
1968
  }
1419
- 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
+ }
1420
1995
  const senderIsBot = bindings.some((b) => b.userId === evt.sender);
1421
1996
  if (evt.type === "m.room.message" && matches.length === 0 && !senderIsBot) {
1422
1997
  console.warn(
@@ -1429,55 +2004,35 @@ function createMatrixTransport(opts) {
1429
2004
  st = { participants: [], rootMentions: [], callers: {}, handoffs: {} };
1430
2005
  threadStates.set(promotedRoot, st);
1431
2006
  }
1432
- const msgMentions = new Set(extractMentions(evt));
1433
- const senderAgent = bindings.find((b) => b.userId === evt.sender);
1434
- for (const a of bindings) {
1435
- if (!msgMentions.has(a.userId)) continue;
1436
- if (!st.rootMentions.includes(a.name)) st.rootMentions.push(a.name);
1437
- if (senderAgent && a.name !== senderAgent.name) {
1438
- st.callers[a.name] = senderAgent.name;
1439
- if (evt.event_id) {
1440
- const arcs = st.handoffs[a.name] ??= [];
1441
- 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
+ }
1442
2021
  }
1443
2022
  }
1444
2023
  }
1445
2024
  }
1446
2025
  for (const a of matches) {
1447
2026
  console.log(`[matrix] \u2192 ${a.name} (${a.userId})`);
1448
- void runTurn(a, evt).then(() => {
1449
- if (!promotedRoot) return;
1450
- let st = threadStates.get(promotedRoot);
1451
- if (!st) {
1452
- st = { participants: [], rootMentions: [], callers: {}, handoffs: {} };
1453
- threadStates.set(promotedRoot, st);
1454
- }
1455
- if (st.participants.at(-1) !== a.name) st.participants.push(a.name);
1456
- }).catch((err) => {
1457
- console.error(`[matrix] runTurn failed for ${a.name}:`, err);
1458
- const c = classify(err);
1459
- const threadRoot = inboundThreadRoot2(evt) ?? evt.event_id;
1460
- if (!threadRoot || !evt.room_id) return;
1461
- const body = toErrorBody(
1462
- {
1463
- kind: "error",
1464
- agentId: a.name,
1465
- sessionId: null,
1466
- turnId: null,
1467
- code: c.code,
1468
- message: err instanceof Error ? err.message : String(err),
1469
- detail: err instanceof Error && err.stack ? err.stack.slice(0, 2e3) : void 0,
1470
- transient: c.transient,
1471
- acp_error: c.acp_error
1472
- },
1473
- threadRoot
1474
- );
1475
- void client.sendCustomEvent({
1476
- roomId: evt.room_id,
1477
- asUserId: a.userId,
1478
- eventType: "dev.zooid.error",
1479
- content: body
1480
- }).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 } : {}
1481
2036
  });
1482
2037
  }
1483
2038
  }
@@ -1518,13 +2073,10 @@ function createMatrixTransport(opts) {
1518
2073
  return c.json({});
1519
2074
  });
1520
2075
  app.get("/healthz", (c) => c.text("ok"));
1521
- async function runTurn(agent, evt) {
1522
- if (!evt.room_id || !evt.event_id) return;
1523
- const inbound = inboundThreadRoot2(evt);
1524
- const threadRoot = inbound ?? evt.event_id;
1525
- const sessionKey = sessionKeyFor(agent.name, threadRoot, threadStates.get(threadRoot));
1526
- const sessionId = await agents.ensureSession(agent.name, sessionKey, evt.room_id, threadRoot);
1527
- 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 });
1528
2080
  buffers.set(sessionId, "");
1529
2081
  bufferMessageIds.delete(sessionId);
1530
2082
  flushedCounts.set(sessionId, 0);
@@ -1533,10 +2085,14 @@ function createMatrixTransport(opts) {
1533
2085
  pendingCommands.delete(sessionId);
1534
2086
  void agents.onEvent?.(agent.name, stashedCommands);
1535
2087
  }
1536
- const roomId = evt.room_id;
1537
2088
  const TYPING_TTL_MS = 3e4;
1538
2089
  const TYPING_REFRESH_MS = 25e3;
1539
- 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));
1540
2096
  const safePresence = (presence) => client.setPresence({ asUserId: agent.userId, presence }).catch(
1541
2097
  (err) => console.warn(`[matrix:${agent.name}] setPresence(${presence}) failed:`, err)
1542
2098
  );
@@ -1545,13 +2101,19 @@ function createMatrixTransport(opts) {
1545
2101
  const refresh = setInterval(() => {
1546
2102
  void safeTyping(true);
1547
2103
  }, TYPING_REFRESH_MS);
2104
+ let turnError;
2105
+ let stopReason;
1548
2106
  try {
1549
- const rawBody = evt.content?.body ?? "";
1550
- 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;
1551
2113
  const pendingItems = pendingMedia.drain(
1552
- evt.room_id,
1553
- inboundThreadRoot2(evt),
1554
- evt.sender ?? ""
2114
+ roomId,
2115
+ input.event ? inboundThreadRoot2(input.event) : void 0,
2116
+ input.event?.sender ?? ""
1555
2117
  );
1556
2118
  const { blocks, pathLines } = await buildMediaBlocks(pendingItems, {
1557
2119
  agent,
@@ -1560,7 +2122,7 @@ function createMatrixTransport(opts) {
1560
2122
  onError: (item, err) => {
1561
2123
  console.warn(`[matrix:${agent.name}] media_failed for ${item.body}:`, err);
1562
2124
  void sendMediaError(
1563
- { agent, roomId: evt.room_id, threadRoot },
2125
+ { agent, roomId, threadRoot },
1564
2126
  err,
1565
2127
  `Could not process attachment: ${item.body}`,
1566
2128
  client
@@ -1568,38 +2130,307 @@ function createMatrixTransport(opts) {
1568
2130
  }
1569
2131
  });
1570
2132
  const fullPromptText = [promptText, ...pathLines].filter(Boolean).join("\n");
1571
- await agents.prompt(agent.name, {
2133
+ const promptResult = await agents.prompt(agent.name, {
1572
2134
  threadId: sessionKey,
1573
- channelId: evt.room_id,
2135
+ channelId: roomId,
1574
2136
  contextThreadId: threadRoot,
1575
2137
  content: [...blocks, { type: "text", text: fullPromptText }]
1576
2138
  });
2139
+ stopReason = promptResult.stopReason;
1577
2140
  const drainStart = Date.now();
1578
2141
  let drained = buffers.get(sessionId) ?? "";
1579
2142
  while (drainQuietMs > 0 && Date.now() - drainStart < drainMaxMs) {
1580
2143
  await delay(drainQuietMs);
1581
2144
  const next = buffers.get(sessionId) ?? "";
1582
- if (next === drained && (next.length > 0 || (flushedCounts.get(sessionId) ?? 0) > 0))
1583
- break;
2145
+ if (next === drained && (next.length > 0 || (flushedCounts.get(sessionId) ?? 0) > 0)) break;
1584
2146
  drained = next;
1585
2147
  }
1586
2148
  flushBuffer(sessionId);
1587
- await (sendQueue.get(sessionId) ?? Promise.resolve());
1588
- if ((flushedCounts.get(sessionId) ?? 0) === 0) {
1589
- console.warn(
1590
- `[matrix:${agent.name}] turn finished with empty buffer (session=${sessionId}); nothing sent to ${evt.room_id}`
1591
- );
1592
- }
2149
+ } catch (err) {
2150
+ turnError = err;
2151
+ throw err;
1593
2152
  } finally {
1594
2153
  clearInterval(refresh);
1595
2154
  await safeTyping(false);
1596
2155
  await safePresence("online");
2156
+ await (sendQueue.get(sessionId) ?? Promise.resolve());
2157
+ const producedOutput = (flushedCounts.get(sessionId) ?? 0) > 0;
2158
+ if (!producedOutput) {
2159
+ console.warn(
2160
+ `[matrix:${agent.name}] turn finished with empty buffer (session=${sessionId}); nothing sent to ${roomId}`
2161
+ );
2162
+ }
2163
+ await client.sendCustomEvent({
2164
+ roomId,
2165
+ asUserId: agent.userId,
2166
+ eventType: "dev.zooid.turn.end",
2167
+ content: toTurnEndBody(
2168
+ {
2169
+ agentId: agent.name,
2170
+ sessionId,
2171
+ producedOutput,
2172
+ lastMessage: lastFlushed.get(sessionId)
2173
+ },
2174
+ threadRoot
2175
+ )
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
+ }
1597
2196
  buffers.delete(sessionId);
1598
2197
  bufferMessageIds.delete(sessionId);
1599
2198
  flushedCounts.delete(sessionId);
2199
+ lastFlushed.delete(sessionId);
1600
2200
  sendQueue.delete(sessionId);
1601
2201
  }
1602
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
+ });
1603
2434
  const syncLoops = mode === "client" ? bindings.map(
1604
2435
  (b) => new SyncLoop({
1605
2436
  client,
@@ -1611,6 +2442,7 @@ function createMatrixTransport(opts) {
1611
2442
  ) : void 0;
1612
2443
  return {
1613
2444
  app,
2445
+ taskActions,
1614
2446
  syncLoops,
1615
2447
  bootstrap: async (bootstrapOpts = {}) => {
1616
2448
  await pool.bootstrap({ adminUserId, ...bootstrapOpts });
@@ -1626,7 +2458,12 @@ function createMatrixTransport(opts) {
1626
2458
  };
1627
2459
  }
1628
2460
  async function rebuildThreadState(client, roomId, rootEventId, bindings) {
1629
- const state = { participants: [], rootMentions: [], callers: {}, handoffs: {} };
2461
+ const state = {
2462
+ participants: [],
2463
+ rootMentions: [],
2464
+ callers: {},
2465
+ handoffs: {}
2466
+ };
1630
2467
  const asUser = (bindings.find((b) => b.rooms.some((r) => r.alias === roomId)) ?? bindings[0])?.userId;
1631
2468
  if (!asUser) return state;
1632
2469
  const root = await client.fetchEvent(roomId, rootEventId, asUser);
@@ -1637,7 +2474,7 @@ async function rebuildThreadState(client, roomId, rootEventId, bindings) {
1637
2474
  for (const a of bindings) {
1638
2475
  if (!rootMentions.has(a.userId)) continue;
1639
2476
  if (!state.rootMentions.includes(a.name)) state.rootMentions.push(a.name);
1640
- if (rootSenderAgent && a.name !== rootSenderAgent.name) {
2477
+ if (rootSenderAgent && a.name !== rootSenderAgent.name && !wouldCycleCallers(state.callers, a.name, rootSenderAgent.name)) {
1641
2478
  state.callers[a.name] = rootSenderAgent.name;
1642
2479
  const arcs = state.handoffs[a.name] ??= [];
1643
2480
  if (!arcs.includes(rootEventId)) arcs.push(rootEventId);
@@ -1657,7 +2494,7 @@ async function rebuildThreadState(client, roomId, rootEventId, bindings) {
1657
2494
  for (const a of bindings) {
1658
2495
  if (!mentions.has(a.userId)) continue;
1659
2496
  if (!state.rootMentions.includes(a.name)) state.rootMentions.push(a.name);
1660
- if (evSenderAgent && a.name !== evSenderAgent.name) {
2497
+ if (evSenderAgent && a.name !== evSenderAgent.name && !wouldCycleCallers(state.callers, a.name, evSenderAgent.name)) {
1661
2498
  state.callers[a.name] = evSenderAgent.name;
1662
2499
  if (evId) {
1663
2500
  const arcs = state.handoffs[a.name] ??= [];
@@ -1726,9 +2563,11 @@ export {
1726
2563
  AGENT_KEY_RE,
1727
2564
  BotPool,
1728
2565
  INLINE_IMAGE_MIMES,
2566
+ InvocationRegistry,
1729
2567
  MAX_DOWNLOAD_BYTES,
1730
2568
  MAX_INLINE_IMAGE_BYTES,
1731
2569
  MAX_MEDIA_PER_TURN,
2570
+ MAX_OPEN_TASKS_PER_ROOM,
1732
2571
  MEDIA_MSGTYPES,
1733
2572
  MatrixClient,
1734
2573
  MatrixContextProvider,
@@ -1736,17 +2575,23 @@ export {
1736
2575
  PendingMediaStore,
1737
2576
  SLUG_RE,
1738
2577
  SyncLoop,
2578
+ TaskRegistry,
1739
2579
  agentMxid,
2580
+ buildAssignmentContent,
1740
2581
  buildWorkforceRoster,
2582
+ checkDelegable,
1741
2583
  createMatrixTransport,
1742
2584
  ensureDefaultChannel,
1743
2585
  ensureWorkforceSpace,
2586
+ evaluateCompletion,
1744
2587
  extractMentions,
1745
2588
  isMediaMsgtype,
1746
2589
  isValidAgentKey,
1747
2590
  isValidWorkstation,
1748
2591
  parseMxcUri,
1749
2592
  publishWorkforce,
2593
+ renderCompletionPrompt,
2594
+ renderInvocationReturn,
1750
2595
  renderRegistration,
1751
2596
  route,
1752
2597
  serverNameFromMxid,