@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.
- package/CHANGELOG.md +19 -0
- package/dist/providers/agent_provider.d.ts.map +1 -1
- package/dist/providers/agent_provider.js +22 -0
- package/dist/providers/agent_provider.js.map +1 -1
- package/dist/src/agent-deps-factory.d.ts +6 -0
- package/dist/src/agent-deps-factory.d.ts.map +1 -1
- package/dist/src/agent-deps-factory.js +3 -0
- package/dist/src/agent-deps-factory.js.map +1 -1
- package/dist/src/agent-deps.d.ts +3 -0
- package/dist/src/agent-deps.d.ts.map +1 -1
- package/dist/src/agent-deps.js.map +1 -1
- package/dist/src/agent-loop.d.ts +9 -0
- package/dist/src/agent-loop.d.ts.map +1 -1
- package/dist/src/agent-loop.js +22 -4
- package/dist/src/agent-loop.js.map +1 -1
- package/dist/src/agent-service.d.ts +8 -0
- package/dist/src/agent-service.d.ts.map +1 -1
- package/dist/src/agent-service.js +4 -0
- package/dist/src/agent-service.js.map +1 -1
- package/dist/src/channels/adapters/evolution_api.d.ts.map +1 -1
- package/dist/src/channels/adapters/evolution_api.js +3 -1
- package/dist/src/channels/adapters/evolution_api.js.map +1 -1
- package/dist/src/channels/adapters/telegram.d.ts.map +1 -1
- package/dist/src/channels/adapters/telegram.js +3 -1
- package/dist/src/channels/adapters/telegram.js.map +1 -1
- package/dist/src/channels/adapters/whatsapp_cloud.d.ts.map +1 -1
- package/dist/src/channels/adapters/whatsapp_cloud.js +3 -1
- package/dist/src/channels/adapters/whatsapp_cloud.js.map +1 -1
- package/dist/src/channels/executor.d.ts +91 -8
- package/dist/src/channels/executor.d.ts.map +1 -1
- package/dist/src/channels/executor.js +144 -20
- package/dist/src/channels/executor.js.map +1 -1
- package/dist/src/channels/handler.d.ts +79 -1
- package/dist/src/channels/handler.d.ts.map +1 -1
- package/dist/src/channels/handler.js +468 -93
- package/dist/src/channels/handler.js.map +1 -1
- package/dist/src/channels/http.d.ts +6 -0
- package/dist/src/channels/http.d.ts.map +1 -1
- package/dist/src/channels/http.js +18 -0
- package/dist/src/channels/http.js.map +1 -1
- package/dist/src/channels/index.d.ts +2 -2
- package/dist/src/channels/index.d.ts.map +1 -1
- package/dist/src/channels/index.js.map +1 -1
- package/dist/src/channels/types.d.ts +8 -0
- package/dist/src/channels/types.d.ts.map +1 -1
- package/dist/src/chat-queue-service.d.ts.map +1 -1
- package/dist/src/chat-queue-service.js +9 -0
- package/dist/src/chat-queue-service.js.map +1 -1
- package/dist/src/define_config.d.ts +11 -0
- package/dist/src/define_config.d.ts.map +1 -1
- package/dist/src/define_config.js.map +1 -1
- package/dist/src/durable/agent-run-context.d.ts +8 -0
- package/dist/src/durable/agent-run-context.d.ts.map +1 -1
- package/dist/src/durable/agent-run-context.js +13 -0
- package/dist/src/durable/agent-run-context.js.map +1 -1
- package/dist/src/index.d.ts +2 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +2 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/message-timestamps.d.ts +61 -0
- package/dist/src/message-timestamps.d.ts.map +1 -0
- package/dist/src/message-timestamps.js +82 -0
- package/dist/src/message-timestamps.js.map +1 -0
- package/dist/src/spi/agent-store.d.ts +2 -0
- package/dist/src/spi/agent-store.d.ts.map +1 -1
- package/dist/src/spi/agent-store.js.map +1 -1
- package/dist/src/spi/chat-queue.d.ts +8 -0
- package/dist/src/spi/chat-queue.d.ts.map +1 -1
- package/dist/src/spi/chat-queue.js.map +1 -1
- package/dist/src/stores/lucid-schema.d.ts +6 -0
- package/dist/src/stores/lucid-schema.d.ts.map +1 -1
- package/dist/src/stores/lucid-schema.js +16 -0
- package/dist/src/stores/lucid-schema.js.map +1 -1
- package/dist/src/stores/lucid.d.ts +10 -0
- package/dist/src/stores/lucid.d.ts.map +1 -1
- package/dist/src/stores/lucid.js +29 -2
- package/dist/src/stores/lucid.js.map +1 -1
- package/dist/src/testing/in-memory-store.d.ts.map +1 -1
- package/dist/src/testing/in-memory-store.js +2 -0
- package/dist/src/testing/in-memory-store.js.map +1 -1
- package/dist/src/types.d.ts +12 -0
- package/dist/src/types.d.ts.map +1 -1
- package/package.json +9 -9
- 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)?.
|
|
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
|
-
|
|
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
|
-
/**
|
|
1030
|
-
const
|
|
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 {
|
|
1042
|
+
return { actor: null, pass: false };
|
|
1046
1043
|
}
|
|
1047
|
-
const
|
|
1048
|
-
if (
|
|
1049
|
-
return {
|
|
1050
|
-
if (
|
|
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
|
|
1052
|
+
for (const reply of 'replies' in decided ? decided.replies : [decided.reply])
|
|
1056
1053
|
await out.reply(reply, 'gate');
|
|
1057
|
-
return {
|
|
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
|
-
|
|
1060
|
-
|
|
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
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
const
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
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
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
/**
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
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
|
|
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
|
-
|
|
1277
|
-
|
|
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
|
|
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
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
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, (
|
|
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
|
-
|
|
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
|
-
|
|
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(() => { });
|