@adonis-agora/agent 0.84.0 → 0.85.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.
@@ -1,12 +1,12 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { DEFAULT_TEXT_ACTION_PROPOSAL_VOCABULARY, parseTextActionProposalCommand, } from '../action-proposal-text.js';
3
3
  import { AgentService, AttachmentRefusedError } from '../agent-service.js';
4
- import { onAgentEngine, registeredAgentEngine } from '../durable/agent-run-context.js';
4
+ import { onAgentEngine, registeredAgentEngine, registeredAgentStepServer, } from '../durable/agent-run-context.js';
5
5
  import { uiActionText } from '../genui/actions.js';
6
6
  import { channelButtonAction, renderChannelMessages, resolveGenuiChannel, } from '../genui/channels.js';
7
7
  import { AgentGenui } from '../genui/factory.js';
8
8
  import { DEFAULT_CONFIRM_VERB } from '../tool-presentation.js';
9
- import { durableExecutor, inlineExecutor, jobId, registerChannelWorkflows, withRetries, } from './executor.js';
9
+ import { durableExecutor, inlineExecutor, inlineIo, isControlFlowSignal, jobId, registerChannelWorkflows, withRetries, } from './executor.js';
10
10
  import { ChannelMediaTooLargeError } from './http.js';
11
11
  import { toChannelMarkdown } from './markdown.js';
12
12
  import { formatChannelQuestion, parseChannelAnswer } from './questions.js';
@@ -62,14 +62,14 @@ async function containerService() {
62
62
  }
63
63
  const registry = new Map();
64
64
  /** The job runner of the channel registered under `channel` in this process. */
65
- const runnerFor = (channel) => registry.get(channel)?.run;
65
+ const runnerFor = (channel) => registry.get(channel)?.runner;
66
66
  let following = false;
67
67
  /** Register the channel workflows on the agent's durable engine, now or once it is wired. */
68
68
  function followAgentEngine() {
69
69
  if (following)
70
70
  return;
71
71
  following = true;
72
- onAgentEngine((engine) => registerChannelWorkflows(engine, runnerFor));
72
+ onAgentEngine((engine) => registerChannelWorkflows(engine, runnerFor, registeredAgentStepServer()));
73
73
  }
74
74
  function record(value) {
75
75
  return value !== null && typeof value === 'object' && !Array.isArray(value)
@@ -728,7 +728,7 @@ export function handleChannel(adapter, options) {
728
728
  return next.streamRunId;
729
729
  };
730
730
  /** Download a message's files and stage them for the turn; refuse what cannot be attached. */
731
- const attachMedia = async (service, texts, out, actor, message) => {
731
+ const attachMedia = async (service, texts, out, actor, message, coalesced) => {
732
732
  const refs = [];
733
733
  const extra = [];
734
734
  const base = service.attachmentLimits?.() ?? null;
@@ -753,9 +753,7 @@ export function handleChannel(adapter, options) {
753
753
  try {
754
754
  let file = await adapter.download(media, { maxBytes: limits.maxBytes });
755
755
  const prepared = await options.prepareMedia?.(file, media, {
756
- channel: name,
757
- conversation: message.conversation,
758
- message,
756
+ ...hookContext(message, coalesced),
759
757
  actor,
760
758
  });
761
759
  if (prepared && 'refuse' in prepared) {
@@ -1021,15 +1019,14 @@ export function handleChannel(adapter, options) {
1021
1019
  await sendProposal(service, store, texts, out, conversation, proposal, left.length > 1);
1022
1020
  return timedOut ? 'timeout' : failed ? 'failed' : blocked ? 'blocked' : 'replied';
1023
1021
  };
1024
- const hookContext = (message) => ({
1022
+ const hookContext = (message, coalesced) => ({
1025
1023
  channel: name,
1026
1024
  conversation: message.conversation,
1027
1025
  message,
1026
+ ...(coalesced !== undefined && coalesced.length > 1 ? { coalesced } : {}),
1028
1027
  });
1029
- /** Before the turn: who is talking, and whether the app lets them through. */
1030
- const prepare = async (message) => {
1031
- const service = await serviceFor();
1032
- const store = await storeFor(service);
1028
+ /** One message's gate: who sent it (`unknownSender` when nobody), and whether `beforeTurn` lets it on. */
1029
+ const gate = async (service, store, message, coalesced) => {
1033
1030
  await adapter.acknowledge?.(message).catch(() => { });
1034
1031
  const actor = await options.actor(message);
1035
1032
  if (actor === null) {
@@ -1038,44 +1035,84 @@ export function handleChannel(adapter, options) {
1038
1035
  message,
1039
1036
  });
1040
1037
  const replies = options.unknownSender
1041
- ? replyList(await options.unknownSender(message, hookContext(message)))
1038
+ ? replyList(await options.unknownSender(message, hookContext(message, coalesced)))
1042
1039
  : replyList((await textsFor(service, { actor: null, message })).unknownSender);
1043
1040
  for (const reply of replies)
1044
1041
  await out.reply(reply, 'gate');
1045
- return { stop: true, actor: null };
1042
+ return { actor: null, pass: false };
1046
1043
  }
1047
- const gate = await options.beforeTurn?.(message, actor, hookContext(message));
1048
- if (gate === 'stop')
1049
- return { stop: true, actor };
1050
- if (gate !== undefined && gate !== 'continue') {
1044
+ const decided = await options.beforeTurn?.(message, actor, hookContext(message, coalesced));
1045
+ if (decided === 'stop')
1046
+ return { actor, pass: false };
1047
+ if (decided !== undefined && decided !== 'continue') {
1051
1048
  const out = outbox(store, `${name}:out:msg:${message.id}:gate`, message.conversation, {
1052
1049
  actor,
1053
1050
  message,
1054
1051
  });
1055
- for (const reply of 'replies' in gate ? gate.replies : [gate.reply])
1052
+ for (const reply of 'replies' in decided ? decided.replies : [decided.reply])
1056
1053
  await out.reply(reply, 'gate');
1057
- return { stop: true, actor };
1054
+ return { actor, pass: false };
1055
+ }
1056
+ return { actor, pass: true };
1057
+ };
1058
+ /**
1059
+ * Before the turn: who is talking, and whether the app lets them through. A burst goes through
1060
+ * message by message — each one acknowledged, mapped and gated as if alone; the turn is made of
1061
+ * those let through, as the first of them's actor.
1062
+ */
1063
+ const prepare = async (received, batch) => {
1064
+ const service = await serviceFor();
1065
+ const store = await storeFor(service);
1066
+ const messages = batch !== undefined && batch.length > 1 ? batch : [received];
1067
+ const coalesced = messages.length > 1 ? messages : undefined;
1068
+ let first;
1069
+ let known = null;
1070
+ const through = [];
1071
+ for (const message of messages) {
1072
+ const gated = await gate(service, store, message, coalesced);
1073
+ known ??= gated.actor;
1074
+ if (!gated.pass || gated.actor === null)
1075
+ continue;
1076
+ first ??= { actor: gated.actor, message };
1077
+ through.push(message);
1058
1078
  }
1059
- const threadId = (await options.thread(actor, message)) ?? null;
1060
- return { stop: false, actor, threadId };
1079
+ if (first === undefined)
1080
+ return { stop: true, actor: known };
1081
+ const threadId = (await options.thread(first.actor, first.message)) ?? null;
1082
+ return {
1083
+ stop: false,
1084
+ actor: first.actor,
1085
+ threadId,
1086
+ ...(coalesced !== undefined ? { messages: through } : {}),
1087
+ };
1061
1088
  };
1089
+ /** A burst led by `leader` was handled (its turn started, or every message was stopped). */
1090
+ const batchDoneKey = (leader) => `${name}:coalesce:done:${leader}`;
1062
1091
  /**
1063
1092
  * The message itself: an answer, a press, a decision, or a turn. Returns the run to read, if any.
1064
1093
  * A turn is started once per message, however often this runs: its run id is kept under the
1065
- * message id before anything else can fail.
1094
+ * message id before anything else can fail. A burst (`batch`, from `coalesce`) is one turn: each
1095
+ * message's files prepared (and refused) as if alone, texts joined in order, files together.
1066
1096
  */
1067
- const act = async (received, actor, threadId) => {
1097
+ const act = async (received, actor, threadId, extra = {}) => {
1068
1098
  const service = await serviceFor();
1069
1099
  const store = await storeFor(service);
1100
+ const batch = extra.batch !== undefined && extra.batch.length > 1 ? extra.batch : undefined;
1070
1101
  const turnKey = `${name}:turn:${received.id}`;
1102
+ const markDone = async () => {
1103
+ if (extra.leader !== undefined)
1104
+ await store.set(batchDoneKey(extra.leader), '1', dedupeTtlMs);
1105
+ };
1071
1106
  const started = await store.get(turnKey);
1072
1107
  if (started !== null && started !== '') {
1073
1108
  // This phase ran to its end before (a retry, a recovery): the same outcome.
1109
+ await markDone();
1074
1110
  return JSON.parse(started);
1075
1111
  }
1076
1112
  /** What this message came to, kept so a phase that runs again does not do it twice. */
1077
1113
  const done = async (result) => {
1078
1114
  await store.set(turnKey, JSON.stringify(result), dedupeTtlMs);
1115
+ await markDone();
1079
1116
  return result;
1080
1117
  };
1081
1118
  const texts = await textsFor(service, { actor, message: received });
@@ -1083,44 +1120,78 @@ export function handleChannel(adapter, options) {
1083
1120
  actor,
1084
1121
  message: received,
1085
1122
  });
1086
- const recovered = await recoverButtonId(service, store, texts, actor, threadId, received);
1087
- const message = recovered === undefined ? received : { ...received, buttonId: recovered };
1088
- const ours = message.buttonId !== undefined && BUTTON_ID.test(message.buttonId);
1089
- if (!ours) {
1090
- const waiting = await store.get(questionKey(message.conversation));
1091
- const pending = waiting === null ? null : JSON.parse(waiting);
1092
- if (pending !== null && (pending.threadId === null || pending.threadId === threadId)) {
1093
- const resume = await answerQuestion(service, store, texts, out, actor, message, pending);
1094
- if (resume !== 'expired')
1095
- return done(resume === null ? null : { runId: resume, threadId: pending.threadId });
1123
+ let message = received;
1124
+ let uiButton = null;
1125
+ if (batch === undefined) {
1126
+ // A burst never holds a control message: those are handled alone.
1127
+ const recovered = await recoverButtonId(service, store, texts, actor, threadId, received);
1128
+ message = recovered === undefined ? received : { ...received, buttonId: recovered };
1129
+ const ours = message.buttonId !== undefined && BUTTON_ID.test(message.buttonId);
1130
+ if (!ours) {
1131
+ const waiting = await store.get(questionKey(message.conversation));
1132
+ const pending = waiting === null ? null : JSON.parse(waiting);
1133
+ if (pending !== null && (pending.threadId === null || pending.threadId === threadId)) {
1134
+ const resume = await answerQuestion(service, store, texts, out, actor, message, pending);
1135
+ if (resume !== 'expired')
1136
+ return done(resume === null ? null : { runId: resume, threadId: pending.threadId });
1137
+ }
1096
1138
  }
1139
+ if (await pressButton(service, out, actor, threadId, message))
1140
+ return done(null);
1141
+ // A component's button (or list entry): the user's next turn, as a UI action — the same thing
1142
+ // a sandbox's `agent.send` and an A2UI button become.
1143
+ uiButton = await pressedUiButton(store, message);
1097
1144
  }
1098
- if (await pressButton(service, out, actor, threadId, message))
1099
- return done(null);
1100
- // A component's button (or list entry): the user's next turn, as a UI action — the same thing a
1101
- // sandbox's `agent.send` and an A2UI button become.
1102
- const uiButton = await pressedUiButton(store, message);
1103
- const media = uiButton === null ? await attachMedia(service, texts, out, actor, message) : null;
1104
- let inbound = {
1105
- text: uiButton !== null
1106
- ? uiActionText(channelButtonAction(uiButton.button, {
1107
- ...(uiButton.componentId !== undefined
1108
- ? { componentId: uiButton.componentId }
1109
- : {}),
1110
- ...(uiButton.title !== undefined ? { title: uiButton.title } : {}),
1111
- }))
1112
- : [message.text, ...(media?.extra ?? [])]
1113
- .filter((part) => part.trim() !== '')
1145
+ let inbound;
1146
+ if (batch !== undefined) {
1147
+ const parts = [];
1148
+ for (const part of batch) {
1149
+ const partOut = part.id === received.id
1150
+ ? out
1151
+ : outbox(store, `${name}:out:msg:${part.id}`, part.conversation, {
1152
+ actor,
1153
+ message: part,
1154
+ });
1155
+ const media = await attachMedia(service, texts, partOut, actor, part, batch);
1156
+ parts.push({
1157
+ message: part,
1158
+ text: [part.text, ...media.extra].filter((piece) => piece.trim() !== '').join('\n\n'),
1159
+ attachments: media.refs,
1160
+ });
1161
+ }
1162
+ inbound = {
1163
+ text: parts
1164
+ .map((part) => part.text)
1165
+ .filter((piece) => piece.trim() !== '')
1114
1166
  .join('\n\n'),
1115
- attachments: media?.refs ?? [],
1116
- };
1167
+ attachments: parts.flatMap((part) => part.attachments),
1168
+ parts,
1169
+ };
1170
+ }
1171
+ else {
1172
+ const media = uiButton === null ? await attachMedia(service, texts, out, actor, message) : null;
1173
+ inbound = {
1174
+ text: uiButton !== null
1175
+ ? uiActionText(channelButtonAction(uiButton.button, {
1176
+ ...(uiButton.componentId !== undefined
1177
+ ? { componentId: uiButton.componentId }
1178
+ : {}),
1179
+ ...(uiButton.title !== undefined ? { title: uiButton.title } : {}),
1180
+ }))
1181
+ : [message.text, ...(media?.extra ?? [])]
1182
+ .filter((part) => part.trim() !== '')
1183
+ .join('\n\n'),
1184
+ attachments: media?.refs ?? [],
1185
+ };
1186
+ }
1117
1187
  if (options.transformInbound)
1118
1188
  inbound = await options.transformInbound(inbound, {
1119
- ...hookContext(message),
1189
+ ...hookContext(message, batch),
1120
1190
  actor,
1121
1191
  threadId,
1122
1192
  });
1123
- if (inbound.attachments.length === 0 &&
1193
+ if (batch === undefined &&
1194
+ inbound.attachments.length === 0 &&
1124
1195
  (await decideByText(service, store, texts, out, actor, threadId, message, inbound.text)))
1125
1196
  return done(null);
1126
1197
  // A file nobody could attach, and no caption: there is nothing left to answer.
@@ -1160,6 +1231,7 @@ export function handleChannel(adapter, options) {
1160
1231
  channel: name,
1161
1232
  conversation: message.conversation,
1162
1233
  messageId: message.id,
1234
+ ...(batch !== undefined ? { messageIds: batch.map((part) => part.id) } : {}),
1163
1235
  },
1164
1236
  }, sendOptions);
1165
1237
  }
@@ -1181,7 +1253,17 @@ export function handleChannel(adapter, options) {
1181
1253
  }
1182
1254
  // A queued message starts later under its own id: its answer is read the same way.
1183
1255
  const runId = sent.queued === true ? (sent.runId ?? sent.messageId) : sent.runId;
1184
- const turn = await done({ runId, threadId: sent.threadId });
1256
+ // What a turn grouped (and how long it waited) is told only with `coalesce` on.
1257
+ const measured = options.coalesce !== undefined;
1258
+ const queueWaitMs = measured && extra.receivedAt !== undefined
1259
+ ? Math.max(0, Date.now() - extra.receivedAt)
1260
+ : undefined;
1261
+ const turn = await done({
1262
+ runId,
1263
+ threadId: sent.threadId,
1264
+ ...(measured ? { attachments: inbound.attachments.length } : {}),
1265
+ ...(queueWaitMs !== undefined ? { queueWaitMs } : {}),
1266
+ });
1185
1267
  if (threadId === null)
1186
1268
  await options.onThreadCreated?.(sent.threadId, actor, message);
1187
1269
  await options.onTurnStarted?.({
@@ -1190,9 +1272,166 @@ export function handleChannel(adapter, options) {
1190
1272
  actor,
1191
1273
  message,
1192
1274
  queued: sent.queued === true,
1275
+ ...(batch !== undefined ? { coalesced: batch } : {}),
1276
+ ...(queueWaitMs !== undefined ? { queueWaitMs } : {}),
1193
1277
  });
1194
1278
  return turn;
1195
1279
  };
1280
+ // ── coalescing: the conversation's burst log ────────────────────────────────
1281
+ //
1282
+ // With `coalesce` on, the webhook appends each message it takes to its conversation's log (a slot
1283
+ // per message, taken with an atomic claim) before starting its job. Jobs still run one at a time
1284
+ // per conversation, in order: the first one to run LEADS a burst — it waits (durably) for the
1285
+ // conversation to go quiet, then claims every later message of the log that may join it (an owner
1286
+ // key per message, claimed once) and makes them one turn. A message's own job, when its turn
1287
+ // comes, finds it owned by a leader whose burst was handled and ends there. A message nobody
1288
+ // claimed (it came after the burst closed, or is a control message) leads the next one. The log is
1289
+ // only how a leader sees what arrived — a message missing from it is still handled by its own job.
1290
+ const logKey = (conversation, slot) => `${name}:coalesce:${conversation}:${slot}`;
1291
+ const logTailKey = (conversation) => `${name}:coalesce:${conversation}:tail`;
1292
+ const logHeadKey = (conversation) => `${name}:coalesce:${conversation}:head`;
1293
+ const ownerKey = (messageId) => `${name}:coalesce:owner:${messageId}`;
1294
+ /** Consecutive empty slots after which the log is taken to end (a slot claimed, never written). */
1295
+ const LOG_GAP = 3;
1296
+ const MAX_LOG_PROBES = 500;
1297
+ /** The longest log entry (a MySQL `TEXT` holds 64 KB). */
1298
+ const MAX_LOG_ENTRY = 60_000;
1299
+ /** Append a message to its conversation's log — a slot taken with a claim, then written. */
1300
+ const appendToLog = async (store, message, at) => {
1301
+ const conversation = message.conversation;
1302
+ const tail = Number((await store.get(logTailKey(conversation))) ?? '0');
1303
+ let slot = Number.isFinite(tail) && tail >= 0 ? tail : 0;
1304
+ // The whole message, for the leader to handle it — without the provider's payload when that
1305
+ // is too big for a store's text column; too big even so → left to its own job.
1306
+ let value = JSON.stringify({ id: message.id, at, message });
1307
+ if (value.length > MAX_LOG_ENTRY)
1308
+ value = JSON.stringify({ id: message.id, at, message: { ...message, raw: null } });
1309
+ if (value.length > MAX_LOG_ENTRY)
1310
+ return;
1311
+ for (let probe = 0; probe < MAX_LOG_PROBES; probe++, slot++) {
1312
+ if (!(await store.claim(logKey(conversation, slot), dedupeTtlMs)))
1313
+ continue;
1314
+ await store.set(logKey(conversation, slot), value, dedupeTtlMs);
1315
+ await store.set(logTailKey(conversation), String(slot + 1), dedupeTtlMs);
1316
+ return;
1317
+ }
1318
+ // A log this full: the message is handled by its own job, alone.
1319
+ };
1320
+ /**
1321
+ * `leader` owns `messageId` — now, or since an earlier run of this phase. A claim left without its
1322
+ * owner (a phase that died between the two writes) is taken: only one job of a conversation looks
1323
+ * at its log at a time.
1324
+ */
1325
+ const own = async (store, messageId, leader) => {
1326
+ const key = ownerKey(messageId);
1327
+ if (!(await store.claim(key, dedupeTtlMs))) {
1328
+ const owner = await store.get(key);
1329
+ if (owner !== null && owner !== '')
1330
+ return owner === leader;
1331
+ }
1332
+ await store.set(key, leader, dedupeTtlMs);
1333
+ return true;
1334
+ };
1335
+ /** A message that is never grouped: a press, a text decision — and what `when` turns away. */
1336
+ const groupable = (service, message) => {
1337
+ if (message.buttonId !== undefined || message.buttonWithoutId === true)
1338
+ return false;
1339
+ if (message.text.trim() !== '') {
1340
+ const vocabulary = service.actionProposalVocabulary?.() ?? DEFAULT_TEXT_ACTION_PROPOSAL_VOCABULARY;
1341
+ if (parseTextActionProposalCommand(message.text, vocabulary).status === 'command')
1342
+ return false;
1343
+ }
1344
+ return options.coalesce?.when?.(message) ?? true;
1345
+ };
1346
+ /**
1347
+ * One look at the burst `job`'s message would lead: wait more (`wakeAt`), or claim it and the
1348
+ * messages that join it. A message a leader already took ends its own job (`absorbed`) — unless
1349
+ * that leader's burst was never handled (it failed before its turn), then it goes on alone.
1350
+ */
1351
+ const gather = async (job) => {
1352
+ const service = await serviceFor();
1353
+ const store = await storeFor(service);
1354
+ const self = job.message;
1355
+ const alone = { batch: [self] };
1356
+ if (!(await own(store, self.id, self.id))) {
1357
+ const owner = await store.get(ownerKey(self.id));
1358
+ if (owner !== null && (await store.get(batchDoneKey(owner))) !== null)
1359
+ return { absorbed: owner };
1360
+ return alone;
1361
+ }
1362
+ const config = options.coalesce;
1363
+ if (config === undefined || !(config.quietMs > 0))
1364
+ return alone;
1365
+ // Every message of a question in progress is an answer, read one at a time.
1366
+ if (!groupable(service, self) || (await store.get(questionKey(self.conversation))) !== null)
1367
+ return alone;
1368
+ const maxMessages = Math.max(1, Math.floor(config.maxMessages ?? 20));
1369
+ const maxWaitMs = config.maxWaitMs ?? Math.max(30_000, config.quietMs);
1370
+ const receivedAt = job.receivedAt ?? Date.now();
1371
+ const conversation = self.conversation;
1372
+ const slotOf = (value) => {
1373
+ const slot = Number(value ?? '0');
1374
+ return Number.isFinite(slot) && slot >= 0 ? Math.floor(slot) : 0;
1375
+ };
1376
+ const head = slotOf(await store.get(logHeadKey(conversation)));
1377
+ const tail = slotOf(await store.get(logTailKey(conversation)));
1378
+ const joining = [];
1379
+ /** Slots of messages nobody had taken when they were read (but this one). */
1380
+ const open = [];
1381
+ let lastSlot;
1382
+ let seenSelf = false;
1383
+ let lastAt = receivedAt;
1384
+ let gap = 0;
1385
+ let closed = false;
1386
+ for (let slot = head; slot < Math.max(head, tail) + MAX_LOG_PROBES; slot++) {
1387
+ const saved = await store.get(logKey(conversation, slot));
1388
+ if (saved === null) {
1389
+ gap++;
1390
+ if (gap >= LOG_GAP && slot >= tail)
1391
+ break;
1392
+ continue;
1393
+ }
1394
+ gap = 0;
1395
+ lastSlot = slot;
1396
+ const entry = JSON.parse(saved);
1397
+ if (entry.id === self.id) {
1398
+ seenSelf = true;
1399
+ continue;
1400
+ }
1401
+ const owner = await store.get(ownerKey(entry.id));
1402
+ if (owner !== null && owner !== '')
1403
+ continue;
1404
+ open.push(slot);
1405
+ // What came before this message (out of order, by a hair) is left to its own job.
1406
+ if (closed || (!seenSelf && entry.at < receivedAt))
1407
+ continue;
1408
+ if (joining.length + 1 >= maxMessages ||
1409
+ entry.message.from !== self.from ||
1410
+ !groupable(service, entry.message)) {
1411
+ closed = true;
1412
+ continue;
1413
+ }
1414
+ joining.push({ ...entry, slot });
1415
+ lastAt = Math.max(lastAt, entry.at);
1416
+ }
1417
+ const now = Date.now();
1418
+ const wakeAt = Math.min(lastAt + config.quietMs, receivedAt + maxWaitMs);
1419
+ if (!closed && joining.length + 1 < maxMessages && now < wakeAt)
1420
+ return { wakeAt };
1421
+ const batch = [self];
1422
+ const taken = new Set();
1423
+ for (const entry of joining)
1424
+ if (await own(store, entry.id, self.id)) {
1425
+ batch.push(entry.message);
1426
+ taken.add(entry.slot);
1427
+ }
1428
+ // Where the next leader starts reading: the first message nobody has taken.
1429
+ const left = open.filter((slot) => !taken.has(slot));
1430
+ const next = left.length > 0 ? Math.min(...left) : lastSlot !== undefined ? lastSlot + 1 : head;
1431
+ if (next !== head)
1432
+ await store.set(logHeadKey(conversation), String(next), dedupeTtlMs);
1433
+ return { batch };
1434
+ };
1196
1435
  const retry = (fn) => withRetries(fn, options.retry);
1197
1436
  const report = (error, message) => {
1198
1437
  if (options.onError && message !== null)
@@ -1217,23 +1456,17 @@ export function handleChannel(adapter, options) {
1217
1456
  });
1218
1457
  }
1219
1458
  };
1220
- /** One job, phase by phase — each phase a checkpoint under a durable engine. */
1221
- const run = async (job, step) => {
1222
- const message = job.kind === 'message' ? job.message : null;
1223
- /** What `onTurnEnded` is told — a question's timeout is not a turn (the resume it starts is). */
1224
- const ended = {
1225
- channel: name,
1226
- conversation: job.conversation,
1227
- actor: job.kind === 'resume' ? job.actor : null,
1228
- message,
1229
- runId: job.kind === 'resume' ? job.runId : null,
1230
- threadId: job.kind === 'resume' ? job.threadId : null,
1231
- // A step a durable engine had checkpointed before is not run again here: what it came to.
1232
- outcome: 'replied',
1233
- };
1234
- try {
1235
- if (job.kind === 'timeout') {
1236
- await step('timeout', () => retry(async () => {
1459
+ /**
1460
+ * One phase of a job, from its input alone — what a dispatched step runs in whatever process it
1461
+ * lands on, and what an inline / local step calls. Each is retried (`retry`) and returns JSON.
1462
+ */
1463
+ const phase = async (which, input) => {
1464
+ const { job } = input;
1465
+ switch (which) {
1466
+ case 'timeout': {
1467
+ if (job.kind !== 'timeout')
1468
+ throw new Error(`phase timeout of a ${job.kind} job`);
1469
+ return retry(async () => {
1237
1470
  const service = await serviceFor();
1238
1471
  const store = await storeFor(service);
1239
1472
  // Answered in time: nothing to do.
@@ -1262,52 +1495,169 @@ export function handleChannel(adapter, options) {
1262
1495
  actor: job.actor,
1263
1496
  }, jobId(name, 'resume', job.runId, job.toolCallId));
1264
1497
  return true;
1498
+ });
1499
+ }
1500
+ case 'gather': {
1501
+ if (job.kind !== 'message')
1502
+ throw new Error(`phase gather of a ${job.kind} job`);
1503
+ return retry(() => gather(job));
1504
+ }
1505
+ case 'prepare': {
1506
+ if (job.kind !== 'message')
1507
+ throw new Error(`phase prepare of a ${job.kind} job`);
1508
+ return retry(async () => {
1509
+ const prepared = await prepare(job.message, input.batch);
1510
+ if (prepared.stop && job.coalesce === true) {
1511
+ // Every message of the burst stopped at its gate: handled.
1512
+ const service = await serviceFor();
1513
+ await (await storeFor(service)).set(batchDoneKey(job.message.id), '1', dedupeTtlMs);
1514
+ }
1515
+ return prepared;
1516
+ });
1517
+ }
1518
+ case 'act': {
1519
+ if (job.kind !== 'message')
1520
+ throw new Error(`phase act of a ${job.kind} job`);
1521
+ const prepared = input.prepared;
1522
+ const messages = prepared.messages ?? [job.message];
1523
+ const [first = job.message] = messages;
1524
+ return retry(() => act(first, prepared.actor, prepared.threadId, {
1525
+ ...(messages.length > 1 ? { batch: messages } : {}),
1526
+ ...(job.receivedAt !== undefined ? { receivedAt: job.receivedAt } : {}),
1527
+ ...(job.coalesce === true ? { leader: job.message.id } : {}),
1265
1528
  }));
1529
+ }
1530
+ case 'read': {
1531
+ return retry(async () => {
1532
+ const service = await serviceFor();
1533
+ const store = await storeFor(service);
1534
+ if (job.kind === 'resume')
1535
+ return readTurn(service, store, job.actor, job.runId, job.threadId, job.conversation, null);
1536
+ if (job.kind !== 'message')
1537
+ throw new Error(`phase read of a ${job.kind} job`);
1538
+ const prepared = input.prepared;
1539
+ const turn = input.turn;
1540
+ const [first = job.message] = prepared.messages ?? [job.message];
1541
+ return readTurn(service, store, prepared.actor, turn.runId, turn.threadId, job.message.conversation, first);
1542
+ });
1543
+ }
1544
+ }
1545
+ };
1546
+ /** What a `read` checkpointed: its outcome (a run recorded before it said so: `replied`). */
1547
+ const outcomeOf = (value) => typeof value === 'string' ? value : 'replied';
1548
+ /** One job, phase by phase — each phase a checkpoint (or a dispatched step) under a durable engine. */
1549
+ const run = async (job, io) => {
1550
+ const message = job.kind === 'message' ? job.message : null;
1551
+ /** What `onTurnEnded` is told — a question's timeout is not a turn (the resume it starts is). */
1552
+ const ended = {
1553
+ channel: name,
1554
+ conversation: job.conversation,
1555
+ actor: job.kind === 'resume' ? job.actor : null,
1556
+ message,
1557
+ runId: job.kind === 'resume' ? job.runId : null,
1558
+ threadId: job.kind === 'resume' ? job.threadId : null,
1559
+ outcome: 'replied',
1560
+ };
1561
+ /** Not told: a burst's wait (the job goes on later), or a message another job's turn took. */
1562
+ let quiet = false;
1563
+ let waiting = false;
1564
+ try {
1565
+ if (job.kind === 'timeout') {
1566
+ await io.phase('timeout', { job });
1266
1567
  return;
1267
1568
  }
1268
1569
  if (job.kind === 'resume') {
1269
- await step('read', () => retry(async () => {
1270
- const service = await serviceFor();
1271
- ended.outcome = await readTurn(service, await storeFor(service), job.actor, job.runId, job.threadId, job.conversation, null);
1272
- return null;
1273
- }));
1570
+ ended.outcome = outcomeOf(await io.phase('read', { job }));
1274
1571
  return;
1275
1572
  }
1276
- const received = job.message;
1277
- const prepared = await step('prepare', () => retry(() => prepare(received)));
1573
+ let batch;
1574
+ if (job.coalesce === true) {
1575
+ for (let round = 0;; round++) {
1576
+ const gathered = await io.phase('gather', { job, round });
1577
+ if ('wakeAt' in gathered) {
1578
+ waiting = true;
1579
+ await io.sleepUntil(gathered.wakeAt);
1580
+ waiting = false;
1581
+ continue;
1582
+ }
1583
+ if ('absorbed' in gathered) {
1584
+ quiet = true;
1585
+ return;
1586
+ }
1587
+ if (gathered.batch.length > 1)
1588
+ batch = gathered.batch;
1589
+ break;
1590
+ }
1591
+ }
1592
+ const prepared = await io.phase('prepare', {
1593
+ job,
1594
+ ...(batch !== undefined ? { batch } : {}),
1595
+ });
1278
1596
  ended.actor = prepared.actor ?? null;
1279
1597
  if (prepared.stop) {
1280
1598
  ended.outcome = 'stopped';
1281
1599
  return;
1282
1600
  }
1283
1601
  ended.threadId = prepared.threadId;
1284
- const turn = await step('act', () => retry(() => act(received, prepared.actor, prepared.threadId)));
1602
+ const through = prepared.messages;
1603
+ if (through !== undefined && through.length > 0)
1604
+ ended.message = through[0] ?? message;
1605
+ if (through !== undefined && through.length > 1)
1606
+ ended.coalesced = through;
1607
+ const turn = await io.phase('act', { job, prepared });
1285
1608
  if (turn === null) {
1286
1609
  ended.outcome = 'handled';
1287
1610
  return;
1288
1611
  }
1289
1612
  ended.runId = turn.runId;
1290
1613
  ended.threadId = turn.threadId;
1291
- await step('read', () => retry(async () => {
1292
- const service = await serviceFor();
1293
- ended.outcome = await readTurn(service, await storeFor(service), prepared.actor, turn.runId, turn.threadId, received.conversation, received);
1294
- return null;
1295
- }));
1614
+ if (turn.attachments !== undefined)
1615
+ ended.attachments = turn.attachments;
1616
+ if (turn.queueWaitMs !== undefined)
1617
+ ended.queueWaitMs = turn.queueWaitMs;
1618
+ ended.outcome = outcomeOf(await io.phase('read', { job, prepared, turn }));
1296
1619
  }
1297
1620
  catch (error) {
1621
+ if (waiting && isControlFlowSignal(error)) {
1622
+ // The burst's wait suspended the run: it goes on (here or elsewhere) once it is due.
1623
+ quiet = true;
1624
+ throw error;
1625
+ }
1298
1626
  if (error?.name === 'WorkflowSuspended') {
1299
1627
  ended.outcome = 'interrupted';
1300
1628
  throw error;
1301
1629
  }
1302
1630
  ended.outcome = 'failed';
1303
1631
  ended.error = error;
1304
- report(error, message);
1632
+ report(error, ended.message);
1305
1633
  }
1306
1634
  finally {
1307
- if (job.kind !== 'timeout')
1635
+ if (job.kind !== 'timeout' && !quiet)
1308
1636
  await turnEnded(ended);
1309
1637
  }
1310
1638
  };
1639
+ /** Each dispatched phase's liveness window: what it may take, with a margin. */
1640
+ const stepTimeoutMs = (which) => {
1641
+ const margin = 2 * 60_000;
1642
+ switch (which) {
1643
+ case 'read':
1644
+ // The turn, the outcome of a proposal it left, and the sends.
1645
+ return timeoutMs + Math.max(0, outcomeTimeoutMs) + margin;
1646
+ case 'act':
1647
+ // Media downloads, `prepareMedia` (a transcription), the turn's start — retried.
1648
+ return 15 * 60_000;
1649
+ case 'prepare':
1650
+ return 10 * 60_000;
1651
+ default:
1652
+ return margin;
1653
+ }
1654
+ };
1655
+ const runner = {
1656
+ run,
1657
+ phase,
1658
+ stepTimeoutMs,
1659
+ ...(options.durableSteps === false ? { dispatched: false } : {}),
1660
+ };
1311
1661
  let current;
1312
1662
  /** Durable when an engine is there (given, or the agent's), else in this process. */
1313
1663
  const executor = () => {
@@ -1321,16 +1671,26 @@ export function handleChannel(adapter, options) {
1321
1671
  if (engine === undefined && options.durable === true)
1322
1672
  console.warn(`[@adonis-agora/agent] channel ${name}: \`durable: true\` but no durable engine is wired (\`durable: true\` in config/agent.ts) — messages are handled in this process`);
1323
1673
  if (engine !== undefined) {
1324
- registerChannelWorkflows(engine, runnerFor);
1674
+ registerChannelWorkflows(engine, runnerFor, stepServerFor(engine));
1325
1675
  current = durableExecutor(engine);
1326
1676
  return current;
1327
1677
  }
1328
- current = inlineExecutor((job) => run(job, (_name, fn) => fn()));
1678
+ current = inlineExecutor((job) => run(job, inlineIo(runner)));
1329
1679
  return current;
1330
1680
  };
1681
+ /** Where `engine`'s channel phases are dispatched: the option, else the agent engine's transport. */
1682
+ function stepServerFor(engine) {
1683
+ if (options.durableSteps === false)
1684
+ return undefined;
1685
+ if (options.durableSteps !== undefined)
1686
+ return options.durableSteps;
1687
+ return engine === registeredAgentEngine()
1688
+ ? registeredAgentStepServer()
1689
+ : undefined;
1690
+ }
1331
1691
  registry.set(name, {
1332
1692
  adapter,
1333
- run,
1693
+ runner,
1334
1694
  relay: (proposal, conversation, service) => relay(proposal, conversation, service),
1335
1695
  follow: (conversation, run) => executor().enqueue({
1336
1696
  kind: 'resume',
@@ -1344,7 +1704,7 @@ export function handleChannel(adapter, options) {
1344
1704
  // Registered on the engine as soon as there is one: a restarted process resumes the jobs a crash
1345
1705
  // interrupted only for workflows it knows.
1346
1706
  if (typeof options.durable === 'object')
1347
- registerChannelWorkflows(options.durable, runnerFor);
1707
+ registerChannelWorkflows(options.durable, runnerFor, stepServerFor(options.durable));
1348
1708
  else if (options.durable !== false)
1349
1709
  followAgentEngine();
1350
1710
  const notify = async (event) => {
@@ -1402,12 +1762,24 @@ export function handleChannel(adapter, options) {
1402
1762
  const store = await storeFor(await serviceFor());
1403
1763
  // Taken before the 200: a store or an engine that is down answers 500, and the provider
1404
1764
  // retries.
1765
+ const coalesce = options.coalesce !== undefined && options.coalesce.quietMs > 0;
1405
1766
  for (const message of messages) {
1406
1767
  const key = `${name}:${message.id}`;
1407
1768
  if (!(await store.claim(key, dedupeTtlMs)))
1408
1769
  continue;
1409
1770
  try {
1410
- await executor().enqueue({ kind: 'message', channel: name, conversation: message.conversation, message }, jobId(name, 'message', message.id));
1771
+ const receivedAt = Date.now();
1772
+ // A burst's leader finds it in the conversation's log; its own job runs either way.
1773
+ if (coalesce)
1774
+ await appendToLog(store, message, receivedAt);
1775
+ await executor().enqueue({
1776
+ kind: 'message',
1777
+ channel: name,
1778
+ conversation: message.conversation,
1779
+ message,
1780
+ receivedAt,
1781
+ ...(coalesce ? { coalesce: true } : {}),
1782
+ }, jobId(name, 'message', message.id));
1411
1783
  }
1412
1784
  catch (error) {
1413
1785
  await Promise.resolve(store.delete(key)).catch(() => { });