@adonis-agora/agent 0.84.0 → 0.86.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.
Files changed (84) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/providers/agent_provider.d.ts.map +1 -1
  3. package/dist/providers/agent_provider.js +22 -0
  4. package/dist/providers/agent_provider.js.map +1 -1
  5. package/dist/src/agent-deps-factory.d.ts +6 -0
  6. package/dist/src/agent-deps-factory.d.ts.map +1 -1
  7. package/dist/src/agent-deps-factory.js +3 -0
  8. package/dist/src/agent-deps-factory.js.map +1 -1
  9. package/dist/src/agent-deps.d.ts +3 -0
  10. package/dist/src/agent-deps.d.ts.map +1 -1
  11. package/dist/src/agent-deps.js.map +1 -1
  12. package/dist/src/agent-loop.d.ts +9 -0
  13. package/dist/src/agent-loop.d.ts.map +1 -1
  14. package/dist/src/agent-loop.js +22 -4
  15. package/dist/src/agent-loop.js.map +1 -1
  16. package/dist/src/agent-service.d.ts +8 -0
  17. package/dist/src/agent-service.d.ts.map +1 -1
  18. package/dist/src/agent-service.js +4 -0
  19. package/dist/src/agent-service.js.map +1 -1
  20. package/dist/src/channels/adapters/evolution_api.d.ts.map +1 -1
  21. package/dist/src/channels/adapters/evolution_api.js +3 -1
  22. package/dist/src/channels/adapters/evolution_api.js.map +1 -1
  23. package/dist/src/channels/adapters/telegram.d.ts.map +1 -1
  24. package/dist/src/channels/adapters/telegram.js +3 -1
  25. package/dist/src/channels/adapters/telegram.js.map +1 -1
  26. package/dist/src/channels/adapters/whatsapp_cloud.d.ts.map +1 -1
  27. package/dist/src/channels/adapters/whatsapp_cloud.js +3 -1
  28. package/dist/src/channels/adapters/whatsapp_cloud.js.map +1 -1
  29. package/dist/src/channels/executor.d.ts +91 -8
  30. package/dist/src/channels/executor.d.ts.map +1 -1
  31. package/dist/src/channels/executor.js +144 -20
  32. package/dist/src/channels/executor.js.map +1 -1
  33. package/dist/src/channels/handler.d.ts +79 -1
  34. package/dist/src/channels/handler.d.ts.map +1 -1
  35. package/dist/src/channels/handler.js +468 -93
  36. package/dist/src/channels/handler.js.map +1 -1
  37. package/dist/src/channels/http.d.ts +6 -0
  38. package/dist/src/channels/http.d.ts.map +1 -1
  39. package/dist/src/channels/http.js +18 -0
  40. package/dist/src/channels/http.js.map +1 -1
  41. package/dist/src/channels/index.d.ts +2 -2
  42. package/dist/src/channels/index.d.ts.map +1 -1
  43. package/dist/src/channels/index.js.map +1 -1
  44. package/dist/src/channels/types.d.ts +8 -0
  45. package/dist/src/channels/types.d.ts.map +1 -1
  46. package/dist/src/chat-queue-service.d.ts.map +1 -1
  47. package/dist/src/chat-queue-service.js +9 -0
  48. package/dist/src/chat-queue-service.js.map +1 -1
  49. package/dist/src/define_config.d.ts +11 -0
  50. package/dist/src/define_config.d.ts.map +1 -1
  51. package/dist/src/define_config.js.map +1 -1
  52. package/dist/src/durable/agent-run-context.d.ts +8 -0
  53. package/dist/src/durable/agent-run-context.d.ts.map +1 -1
  54. package/dist/src/durable/agent-run-context.js +13 -0
  55. package/dist/src/durable/agent-run-context.js.map +1 -1
  56. package/dist/src/index.d.ts +2 -1
  57. package/dist/src/index.d.ts.map +1 -1
  58. package/dist/src/index.js +2 -1
  59. package/dist/src/index.js.map +1 -1
  60. package/dist/src/message-timestamps.d.ts +61 -0
  61. package/dist/src/message-timestamps.d.ts.map +1 -0
  62. package/dist/src/message-timestamps.js +82 -0
  63. package/dist/src/message-timestamps.js.map +1 -0
  64. package/dist/src/spi/agent-store.d.ts +2 -0
  65. package/dist/src/spi/agent-store.d.ts.map +1 -1
  66. package/dist/src/spi/agent-store.js.map +1 -1
  67. package/dist/src/spi/chat-queue.d.ts +8 -0
  68. package/dist/src/spi/chat-queue.d.ts.map +1 -1
  69. package/dist/src/spi/chat-queue.js.map +1 -1
  70. package/dist/src/stores/lucid-schema.d.ts +6 -0
  71. package/dist/src/stores/lucid-schema.d.ts.map +1 -1
  72. package/dist/src/stores/lucid-schema.js +16 -0
  73. package/dist/src/stores/lucid-schema.js.map +1 -1
  74. package/dist/src/stores/lucid.d.ts +10 -0
  75. package/dist/src/stores/lucid.d.ts.map +1 -1
  76. package/dist/src/stores/lucid.js +29 -2
  77. package/dist/src/stores/lucid.js.map +1 -1
  78. package/dist/src/testing/in-memory-store.d.ts.map +1 -1
  79. package/dist/src/testing/in-memory-store.js +2 -0
  80. package/dist/src/testing/in-memory-store.js.map +1 -1
  81. package/dist/src/types.d.ts +12 -0
  82. package/dist/src/types.d.ts.map +1 -1
  83. package/package.json +9 -9
  84. package/skills/agent-setup/SKILL.md +6 -0
@@ -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.
@@ -1144,6 +1215,8 @@ export function handleChannel(adapter, options) {
1144
1215
  // narrowed to it), and the components arrive whole, for the history and for native delivery.
1145
1216
  const drawsNatively = options.uiCapabilities === undefined && (await genuiFor()) !== null;
1146
1217
  const sendOptions = { textDecisions: false };
1218
+ // When the person said it: the provider's time — a burst's first message's, when it begins.
1219
+ const sentAt = (batch ?? [message]).find((part) => part.sentAt !== undefined)?.sentAt;
1147
1220
  let sent;
1148
1221
  try {
1149
1222
  sent = await service.send({
@@ -1152,6 +1225,7 @@ export function handleChannel(adapter, options) {
1152
1225
  ...(threadId !== null ? { threadId } : {}),
1153
1226
  ...(options.agentName !== undefined ? { agentName: options.agentName } : {}),
1154
1227
  ...(inbound.attachments.length > 0 ? { attachments: inbound.attachments } : {}),
1228
+ ...(sentAt !== undefined ? { sentAt } : {}),
1155
1229
  ...(drawsNatively
1156
1230
  ? {}
1157
1231
  : { uiCapabilities: options.uiCapabilities ?? { components: [] } }),
@@ -1160,6 +1234,7 @@ export function handleChannel(adapter, options) {
1160
1234
  channel: name,
1161
1235
  conversation: message.conversation,
1162
1236
  messageId: message.id,
1237
+ ...(batch !== undefined ? { messageIds: batch.map((part) => part.id) } : {}),
1163
1238
  },
1164
1239
  }, sendOptions);
1165
1240
  }
@@ -1181,7 +1256,17 @@ export function handleChannel(adapter, options) {
1181
1256
  }
1182
1257
  // A queued message starts later under its own id: its answer is read the same way.
1183
1258
  const runId = sent.queued === true ? (sent.runId ?? sent.messageId) : sent.runId;
1184
- const turn = await done({ runId, threadId: sent.threadId });
1259
+ // What a turn grouped (and how long it waited) is told only with `coalesce` on.
1260
+ const measured = options.coalesce !== undefined;
1261
+ const queueWaitMs = measured && extra.receivedAt !== undefined
1262
+ ? Math.max(0, Date.now() - extra.receivedAt)
1263
+ : undefined;
1264
+ const turn = await done({
1265
+ runId,
1266
+ threadId: sent.threadId,
1267
+ ...(measured ? { attachments: inbound.attachments.length } : {}),
1268
+ ...(queueWaitMs !== undefined ? { queueWaitMs } : {}),
1269
+ });
1185
1270
  if (threadId === null)
1186
1271
  await options.onThreadCreated?.(sent.threadId, actor, message);
1187
1272
  await options.onTurnStarted?.({
@@ -1190,9 +1275,166 @@ export function handleChannel(adapter, options) {
1190
1275
  actor,
1191
1276
  message,
1192
1277
  queued: sent.queued === true,
1278
+ ...(batch !== undefined ? { coalesced: batch } : {}),
1279
+ ...(queueWaitMs !== undefined ? { queueWaitMs } : {}),
1193
1280
  });
1194
1281
  return turn;
1195
1282
  };
1283
+ // ── coalescing: the conversation's burst log ────────────────────────────────
1284
+ //
1285
+ // With `coalesce` on, the webhook appends each message it takes to its conversation's log (a slot
1286
+ // per message, taken with an atomic claim) before starting its job. Jobs still run one at a time
1287
+ // per conversation, in order: the first one to run LEADS a burst — it waits (durably) for the
1288
+ // conversation to go quiet, then claims every later message of the log that may join it (an owner
1289
+ // key per message, claimed once) and makes them one turn. A message's own job, when its turn
1290
+ // comes, finds it owned by a leader whose burst was handled and ends there. A message nobody
1291
+ // claimed (it came after the burst closed, or is a control message) leads the next one. The log is
1292
+ // only how a leader sees what arrived — a message missing from it is still handled by its own job.
1293
+ const logKey = (conversation, slot) => `${name}:coalesce:${conversation}:${slot}`;
1294
+ const logTailKey = (conversation) => `${name}:coalesce:${conversation}:tail`;
1295
+ const logHeadKey = (conversation) => `${name}:coalesce:${conversation}:head`;
1296
+ const ownerKey = (messageId) => `${name}:coalesce:owner:${messageId}`;
1297
+ /** Consecutive empty slots after which the log is taken to end (a slot claimed, never written). */
1298
+ const LOG_GAP = 3;
1299
+ const MAX_LOG_PROBES = 500;
1300
+ /** The longest log entry (a MySQL `TEXT` holds 64 KB). */
1301
+ const MAX_LOG_ENTRY = 60_000;
1302
+ /** Append a message to its conversation's log — a slot taken with a claim, then written. */
1303
+ const appendToLog = async (store, message, at) => {
1304
+ const conversation = message.conversation;
1305
+ const tail = Number((await store.get(logTailKey(conversation))) ?? '0');
1306
+ let slot = Number.isFinite(tail) && tail >= 0 ? tail : 0;
1307
+ // The whole message, for the leader to handle it — without the provider's payload when that
1308
+ // is too big for a store's text column; too big even so → left to its own job.
1309
+ let value = JSON.stringify({ id: message.id, at, message });
1310
+ if (value.length > MAX_LOG_ENTRY)
1311
+ value = JSON.stringify({ id: message.id, at, message: { ...message, raw: null } });
1312
+ if (value.length > MAX_LOG_ENTRY)
1313
+ return;
1314
+ for (let probe = 0; probe < MAX_LOG_PROBES; probe++, slot++) {
1315
+ if (!(await store.claim(logKey(conversation, slot), dedupeTtlMs)))
1316
+ continue;
1317
+ await store.set(logKey(conversation, slot), value, dedupeTtlMs);
1318
+ await store.set(logTailKey(conversation), String(slot + 1), dedupeTtlMs);
1319
+ return;
1320
+ }
1321
+ // A log this full: the message is handled by its own job, alone.
1322
+ };
1323
+ /**
1324
+ * `leader` owns `messageId` — now, or since an earlier run of this phase. A claim left without its
1325
+ * owner (a phase that died between the two writes) is taken: only one job of a conversation looks
1326
+ * at its log at a time.
1327
+ */
1328
+ const own = async (store, messageId, leader) => {
1329
+ const key = ownerKey(messageId);
1330
+ if (!(await store.claim(key, dedupeTtlMs))) {
1331
+ const owner = await store.get(key);
1332
+ if (owner !== null && owner !== '')
1333
+ return owner === leader;
1334
+ }
1335
+ await store.set(key, leader, dedupeTtlMs);
1336
+ return true;
1337
+ };
1338
+ /** A message that is never grouped: a press, a text decision — and what `when` turns away. */
1339
+ const groupable = (service, message) => {
1340
+ if (message.buttonId !== undefined || message.buttonWithoutId === true)
1341
+ return false;
1342
+ if (message.text.trim() !== '') {
1343
+ const vocabulary = service.actionProposalVocabulary?.() ?? DEFAULT_TEXT_ACTION_PROPOSAL_VOCABULARY;
1344
+ if (parseTextActionProposalCommand(message.text, vocabulary).status === 'command')
1345
+ return false;
1346
+ }
1347
+ return options.coalesce?.when?.(message) ?? true;
1348
+ };
1349
+ /**
1350
+ * One look at the burst `job`'s message would lead: wait more (`wakeAt`), or claim it and the
1351
+ * messages that join it. A message a leader already took ends its own job (`absorbed`) — unless
1352
+ * that leader's burst was never handled (it failed before its turn), then it goes on alone.
1353
+ */
1354
+ const gather = async (job) => {
1355
+ const service = await serviceFor();
1356
+ const store = await storeFor(service);
1357
+ const self = job.message;
1358
+ const alone = { batch: [self] };
1359
+ if (!(await own(store, self.id, self.id))) {
1360
+ const owner = await store.get(ownerKey(self.id));
1361
+ if (owner !== null && (await store.get(batchDoneKey(owner))) !== null)
1362
+ return { absorbed: owner };
1363
+ return alone;
1364
+ }
1365
+ const config = options.coalesce;
1366
+ if (config === undefined || !(config.quietMs > 0))
1367
+ return alone;
1368
+ // Every message of a question in progress is an answer, read one at a time.
1369
+ if (!groupable(service, self) || (await store.get(questionKey(self.conversation))) !== null)
1370
+ return alone;
1371
+ const maxMessages = Math.max(1, Math.floor(config.maxMessages ?? 20));
1372
+ const maxWaitMs = config.maxWaitMs ?? Math.max(30_000, config.quietMs);
1373
+ const receivedAt = job.receivedAt ?? Date.now();
1374
+ const conversation = self.conversation;
1375
+ const slotOf = (value) => {
1376
+ const slot = Number(value ?? '0');
1377
+ return Number.isFinite(slot) && slot >= 0 ? Math.floor(slot) : 0;
1378
+ };
1379
+ const head = slotOf(await store.get(logHeadKey(conversation)));
1380
+ const tail = slotOf(await store.get(logTailKey(conversation)));
1381
+ const joining = [];
1382
+ /** Slots of messages nobody had taken when they were read (but this one). */
1383
+ const open = [];
1384
+ let lastSlot;
1385
+ let seenSelf = false;
1386
+ let lastAt = receivedAt;
1387
+ let gap = 0;
1388
+ let closed = false;
1389
+ for (let slot = head; slot < Math.max(head, tail) + MAX_LOG_PROBES; slot++) {
1390
+ const saved = await store.get(logKey(conversation, slot));
1391
+ if (saved === null) {
1392
+ gap++;
1393
+ if (gap >= LOG_GAP && slot >= tail)
1394
+ break;
1395
+ continue;
1396
+ }
1397
+ gap = 0;
1398
+ lastSlot = slot;
1399
+ const entry = JSON.parse(saved);
1400
+ if (entry.id === self.id) {
1401
+ seenSelf = true;
1402
+ continue;
1403
+ }
1404
+ const owner = await store.get(ownerKey(entry.id));
1405
+ if (owner !== null && owner !== '')
1406
+ continue;
1407
+ open.push(slot);
1408
+ // What came before this message (out of order, by a hair) is left to its own job.
1409
+ if (closed || (!seenSelf && entry.at < receivedAt))
1410
+ continue;
1411
+ if (joining.length + 1 >= maxMessages ||
1412
+ entry.message.from !== self.from ||
1413
+ !groupable(service, entry.message)) {
1414
+ closed = true;
1415
+ continue;
1416
+ }
1417
+ joining.push({ ...entry, slot });
1418
+ lastAt = Math.max(lastAt, entry.at);
1419
+ }
1420
+ const now = Date.now();
1421
+ const wakeAt = Math.min(lastAt + config.quietMs, receivedAt + maxWaitMs);
1422
+ if (!closed && joining.length + 1 < maxMessages && now < wakeAt)
1423
+ return { wakeAt };
1424
+ const batch = [self];
1425
+ const taken = new Set();
1426
+ for (const entry of joining)
1427
+ if (await own(store, entry.id, self.id)) {
1428
+ batch.push(entry.message);
1429
+ taken.add(entry.slot);
1430
+ }
1431
+ // Where the next leader starts reading: the first message nobody has taken.
1432
+ const left = open.filter((slot) => !taken.has(slot));
1433
+ const next = left.length > 0 ? Math.min(...left) : lastSlot !== undefined ? lastSlot + 1 : head;
1434
+ if (next !== head)
1435
+ await store.set(logHeadKey(conversation), String(next), dedupeTtlMs);
1436
+ return { batch };
1437
+ };
1196
1438
  const retry = (fn) => withRetries(fn, options.retry);
1197
1439
  const report = (error, message) => {
1198
1440
  if (options.onError && message !== null)
@@ -1217,23 +1459,17 @@ export function handleChannel(adapter, options) {
1217
1459
  });
1218
1460
  }
1219
1461
  };
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 () => {
1462
+ /**
1463
+ * One phase of a job, from its input alone — what a dispatched step runs in whatever process it
1464
+ * lands on, and what an inline / local step calls. Each is retried (`retry`) and returns JSON.
1465
+ */
1466
+ const phase = async (which, input) => {
1467
+ const { job } = input;
1468
+ switch (which) {
1469
+ case 'timeout': {
1470
+ if (job.kind !== 'timeout')
1471
+ throw new Error(`phase timeout of a ${job.kind} job`);
1472
+ return retry(async () => {
1237
1473
  const service = await serviceFor();
1238
1474
  const store = await storeFor(service);
1239
1475
  // Answered in time: nothing to do.
@@ -1262,52 +1498,169 @@ export function handleChannel(adapter, options) {
1262
1498
  actor: job.actor,
1263
1499
  }, jobId(name, 'resume', job.runId, job.toolCallId));
1264
1500
  return true;
1501
+ });
1502
+ }
1503
+ case 'gather': {
1504
+ if (job.kind !== 'message')
1505
+ throw new Error(`phase gather of a ${job.kind} job`);
1506
+ return retry(() => gather(job));
1507
+ }
1508
+ case 'prepare': {
1509
+ if (job.kind !== 'message')
1510
+ throw new Error(`phase prepare of a ${job.kind} job`);
1511
+ return retry(async () => {
1512
+ const prepared = await prepare(job.message, input.batch);
1513
+ if (prepared.stop && job.coalesce === true) {
1514
+ // Every message of the burst stopped at its gate: handled.
1515
+ const service = await serviceFor();
1516
+ await (await storeFor(service)).set(batchDoneKey(job.message.id), '1', dedupeTtlMs);
1517
+ }
1518
+ return prepared;
1519
+ });
1520
+ }
1521
+ case 'act': {
1522
+ if (job.kind !== 'message')
1523
+ throw new Error(`phase act of a ${job.kind} job`);
1524
+ const prepared = input.prepared;
1525
+ const messages = prepared.messages ?? [job.message];
1526
+ const [first = job.message] = messages;
1527
+ return retry(() => act(first, prepared.actor, prepared.threadId, {
1528
+ ...(messages.length > 1 ? { batch: messages } : {}),
1529
+ ...(job.receivedAt !== undefined ? { receivedAt: job.receivedAt } : {}),
1530
+ ...(job.coalesce === true ? { leader: job.message.id } : {}),
1265
1531
  }));
1532
+ }
1533
+ case 'read': {
1534
+ return retry(async () => {
1535
+ const service = await serviceFor();
1536
+ const store = await storeFor(service);
1537
+ if (job.kind === 'resume')
1538
+ return readTurn(service, store, job.actor, job.runId, job.threadId, job.conversation, null);
1539
+ if (job.kind !== 'message')
1540
+ throw new Error(`phase read of a ${job.kind} job`);
1541
+ const prepared = input.prepared;
1542
+ const turn = input.turn;
1543
+ const [first = job.message] = prepared.messages ?? [job.message];
1544
+ return readTurn(service, store, prepared.actor, turn.runId, turn.threadId, job.message.conversation, first);
1545
+ });
1546
+ }
1547
+ }
1548
+ };
1549
+ /** What a `read` checkpointed: its outcome (a run recorded before it said so: `replied`). */
1550
+ const outcomeOf = (value) => typeof value === 'string' ? value : 'replied';
1551
+ /** One job, phase by phase — each phase a checkpoint (or a dispatched step) under a durable engine. */
1552
+ const run = async (job, io) => {
1553
+ const message = job.kind === 'message' ? job.message : null;
1554
+ /** What `onTurnEnded` is told — a question's timeout is not a turn (the resume it starts is). */
1555
+ const ended = {
1556
+ channel: name,
1557
+ conversation: job.conversation,
1558
+ actor: job.kind === 'resume' ? job.actor : null,
1559
+ message,
1560
+ runId: job.kind === 'resume' ? job.runId : null,
1561
+ threadId: job.kind === 'resume' ? job.threadId : null,
1562
+ outcome: 'replied',
1563
+ };
1564
+ /** Not told: a burst's wait (the job goes on later), or a message another job's turn took. */
1565
+ let quiet = false;
1566
+ let waiting = false;
1567
+ try {
1568
+ if (job.kind === 'timeout') {
1569
+ await io.phase('timeout', { job });
1266
1570
  return;
1267
1571
  }
1268
1572
  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
- }));
1573
+ ended.outcome = outcomeOf(await io.phase('read', { job }));
1274
1574
  return;
1275
1575
  }
1276
- const received = job.message;
1277
- const prepared = await step('prepare', () => retry(() => prepare(received)));
1576
+ let batch;
1577
+ if (job.coalesce === true) {
1578
+ for (let round = 0;; round++) {
1579
+ const gathered = await io.phase('gather', { job, round });
1580
+ if ('wakeAt' in gathered) {
1581
+ waiting = true;
1582
+ await io.sleepUntil(gathered.wakeAt);
1583
+ waiting = false;
1584
+ continue;
1585
+ }
1586
+ if ('absorbed' in gathered) {
1587
+ quiet = true;
1588
+ return;
1589
+ }
1590
+ if (gathered.batch.length > 1)
1591
+ batch = gathered.batch;
1592
+ break;
1593
+ }
1594
+ }
1595
+ const prepared = await io.phase('prepare', {
1596
+ job,
1597
+ ...(batch !== undefined ? { batch } : {}),
1598
+ });
1278
1599
  ended.actor = prepared.actor ?? null;
1279
1600
  if (prepared.stop) {
1280
1601
  ended.outcome = 'stopped';
1281
1602
  return;
1282
1603
  }
1283
1604
  ended.threadId = prepared.threadId;
1284
- const turn = await step('act', () => retry(() => act(received, prepared.actor, prepared.threadId)));
1605
+ const through = prepared.messages;
1606
+ if (through !== undefined && through.length > 0)
1607
+ ended.message = through[0] ?? message;
1608
+ if (through !== undefined && through.length > 1)
1609
+ ended.coalesced = through;
1610
+ const turn = await io.phase('act', { job, prepared });
1285
1611
  if (turn === null) {
1286
1612
  ended.outcome = 'handled';
1287
1613
  return;
1288
1614
  }
1289
1615
  ended.runId = turn.runId;
1290
1616
  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
- }));
1617
+ if (turn.attachments !== undefined)
1618
+ ended.attachments = turn.attachments;
1619
+ if (turn.queueWaitMs !== undefined)
1620
+ ended.queueWaitMs = turn.queueWaitMs;
1621
+ ended.outcome = outcomeOf(await io.phase('read', { job, prepared, turn }));
1296
1622
  }
1297
1623
  catch (error) {
1624
+ if (waiting && isControlFlowSignal(error)) {
1625
+ // The burst's wait suspended the run: it goes on (here or elsewhere) once it is due.
1626
+ quiet = true;
1627
+ throw error;
1628
+ }
1298
1629
  if (error?.name === 'WorkflowSuspended') {
1299
1630
  ended.outcome = 'interrupted';
1300
1631
  throw error;
1301
1632
  }
1302
1633
  ended.outcome = 'failed';
1303
1634
  ended.error = error;
1304
- report(error, message);
1635
+ report(error, ended.message);
1305
1636
  }
1306
1637
  finally {
1307
- if (job.kind !== 'timeout')
1638
+ if (job.kind !== 'timeout' && !quiet)
1308
1639
  await turnEnded(ended);
1309
1640
  }
1310
1641
  };
1642
+ /** Each dispatched phase's liveness window: what it may take, with a margin. */
1643
+ const stepTimeoutMs = (which) => {
1644
+ const margin = 2 * 60_000;
1645
+ switch (which) {
1646
+ case 'read':
1647
+ // The turn, the outcome of a proposal it left, and the sends.
1648
+ return timeoutMs + Math.max(0, outcomeTimeoutMs) + margin;
1649
+ case 'act':
1650
+ // Media downloads, `prepareMedia` (a transcription), the turn's start — retried.
1651
+ return 15 * 60_000;
1652
+ case 'prepare':
1653
+ return 10 * 60_000;
1654
+ default:
1655
+ return margin;
1656
+ }
1657
+ };
1658
+ const runner = {
1659
+ run,
1660
+ phase,
1661
+ stepTimeoutMs,
1662
+ ...(options.durableSteps === false ? { dispatched: false } : {}),
1663
+ };
1311
1664
  let current;
1312
1665
  /** Durable when an engine is there (given, or the agent's), else in this process. */
1313
1666
  const executor = () => {
@@ -1321,16 +1674,26 @@ export function handleChannel(adapter, options) {
1321
1674
  if (engine === undefined && options.durable === true)
1322
1675
  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
1676
  if (engine !== undefined) {
1324
- registerChannelWorkflows(engine, runnerFor);
1677
+ registerChannelWorkflows(engine, runnerFor, stepServerFor(engine));
1325
1678
  current = durableExecutor(engine);
1326
1679
  return current;
1327
1680
  }
1328
- current = inlineExecutor((job) => run(job, (_name, fn) => fn()));
1681
+ current = inlineExecutor((job) => run(job, inlineIo(runner)));
1329
1682
  return current;
1330
1683
  };
1684
+ /** Where `engine`'s channel phases are dispatched: the option, else the agent engine's transport. */
1685
+ function stepServerFor(engine) {
1686
+ if (options.durableSteps === false)
1687
+ return undefined;
1688
+ if (options.durableSteps !== undefined)
1689
+ return options.durableSteps;
1690
+ return engine === registeredAgentEngine()
1691
+ ? registeredAgentStepServer()
1692
+ : undefined;
1693
+ }
1331
1694
  registry.set(name, {
1332
1695
  adapter,
1333
- run,
1696
+ runner,
1334
1697
  relay: (proposal, conversation, service) => relay(proposal, conversation, service),
1335
1698
  follow: (conversation, run) => executor().enqueue({
1336
1699
  kind: 'resume',
@@ -1344,7 +1707,7 @@ export function handleChannel(adapter, options) {
1344
1707
  // Registered on the engine as soon as there is one: a restarted process resumes the jobs a crash
1345
1708
  // interrupted only for workflows it knows.
1346
1709
  if (typeof options.durable === 'object')
1347
- registerChannelWorkflows(options.durable, runnerFor);
1710
+ registerChannelWorkflows(options.durable, runnerFor, stepServerFor(options.durable));
1348
1711
  else if (options.durable !== false)
1349
1712
  followAgentEngine();
1350
1713
  const notify = async (event) => {
@@ -1402,12 +1765,24 @@ export function handleChannel(adapter, options) {
1402
1765
  const store = await storeFor(await serviceFor());
1403
1766
  // Taken before the 200: a store or an engine that is down answers 500, and the provider
1404
1767
  // retries.
1768
+ const coalesce = options.coalesce !== undefined && options.coalesce.quietMs > 0;
1405
1769
  for (const message of messages) {
1406
1770
  const key = `${name}:${message.id}`;
1407
1771
  if (!(await store.claim(key, dedupeTtlMs)))
1408
1772
  continue;
1409
1773
  try {
1410
- await executor().enqueue({ kind: 'message', channel: name, conversation: message.conversation, message }, jobId(name, 'message', message.id));
1774
+ const receivedAt = Date.now();
1775
+ // A burst's leader finds it in the conversation's log; its own job runs either way.
1776
+ if (coalesce)
1777
+ await appendToLog(store, message, receivedAt);
1778
+ await executor().enqueue({
1779
+ kind: 'message',
1780
+ channel: name,
1781
+ conversation: message.conversation,
1782
+ message,
1783
+ receivedAt,
1784
+ ...(coalesce ? { coalesce: true } : {}),
1785
+ }, jobId(name, 'message', message.id));
1411
1786
  }
1412
1787
  catch (error) {
1413
1788
  await Promise.resolve(store.delete(key)).catch(() => { });