@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.
- package/CHANGELOG.md +9 -0
- package/dist/providers/agent_provider.d.ts.map +1 -1
- package/dist/providers/agent_provider.js +16 -0
- package/dist/providers/agent_provider.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 +465 -93
- package/dist/src/channels/handler.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/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 +1 -1
- package/dist/src/index.js +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.
|
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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 () => {
|
|
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
|
|
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
|
-
|
|
1277
|
-
|
|
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
|
|
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
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
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, (
|
|
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
|
-
|
|
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
|
-
|
|
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(() => { });
|