@adonis-agora/agent 0.64.1 → 0.65.1
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 +17 -0
- package/dist/src/agent-service.d.ts +16 -1
- package/dist/src/agent-service.d.ts.map +1 -1
- package/dist/src/agent-service.js +11 -2
- 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 +25 -1
- package/dist/src/channels/adapters/evolution_api.js.map +1 -1
- package/dist/src/channels/adapters/telegram.d.ts +2 -1
- package/dist/src/channels/adapters/telegram.d.ts.map +1 -1
- package/dist/src/channels/adapters/telegram.js +57 -4
- package/dist/src/channels/adapters/telegram.js.map +1 -1
- package/dist/src/channels/adapters/whatsapp_cloud.d.ts +3 -2
- package/dist/src/channels/adapters/whatsapp_cloud.d.ts.map +1 -1
- package/dist/src/channels/adapters/whatsapp_cloud.js +55 -4
- package/dist/src/channels/adapters/whatsapp_cloud.js.map +1 -1
- package/dist/src/channels/executor.d.ts +116 -0
- package/dist/src/channels/executor.d.ts.map +1 -0
- package/dist/src/channels/executor.js +138 -0
- package/dist/src/channels/executor.js.map +1 -0
- package/dist/src/channels/handler.d.ts +228 -89
- package/dist/src/channels/handler.d.ts.map +1 -1
- package/dist/src/channels/handler.js +856 -388
- package/dist/src/channels/handler.js.map +1 -1
- package/dist/src/channels/http.d.ts +15 -0
- package/dist/src/channels/http.d.ts.map +1 -1
- package/dist/src/channels/http.js +43 -0
- package/dist/src/channels/http.js.map +1 -1
- package/dist/src/channels/index.d.ts +7 -4
- package/dist/src/channels/index.d.ts.map +1 -1
- package/dist/src/channels/index.js +6 -3
- package/dist/src/channels/index.js.map +1 -1
- package/dist/src/channels/texts.d.ts +102 -0
- package/dist/src/channels/texts.d.ts.map +1 -0
- package/dist/src/channels/texts.js +79 -0
- package/dist/src/channels/texts.js.map +1 -0
- package/dist/src/channels/types.d.ts +41 -1
- package/dist/src/channels/types.d.ts.map +1 -1
- package/dist/src/durable/agent-run-context.d.ts +10 -0
- package/dist/src/durable/agent-run-context.d.ts.map +1 -1
- package/dist/src/durable/agent-run-context.js +22 -0
- package/dist/src/durable/agent-run-context.js.map +1 -1
- package/dist/src/durable/agent-run-workflow.d.ts.map +1 -1
- package/dist/src/durable/agent-run-workflow.js +1 -27
- package/dist/src/durable/agent-run-workflow.js.map +1 -1
- package/dist/src/durable/drive-pending-runs.d.ts +17 -0
- package/dist/src/durable/drive-pending-runs.d.ts.map +1 -0
- package/dist/src/durable/drive-pending-runs.js +41 -0
- package/dist/src/durable/drive-pending-runs.js.map +1 -0
- package/dist/src/durable/durable-agent-runner.d.ts +4 -0
- package/dist/src/durable/durable-agent-runner.d.ts.map +1 -1
- package/dist/src/durable/durable-agent-runner.js +7 -0
- package/dist/src/durable/durable-agent-runner.js.map +1 -1
- package/dist/src/durable/outside-workflow-ctx.d.ts +27 -0
- package/dist/src/durable/outside-workflow-ctx.d.ts.map +1 -0
- package/dist/src/durable/outside-workflow-ctx.js +29 -0
- package/dist/src/durable/outside-workflow-ctx.js.map +1 -0
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +1 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/opencode/durable/runner.d.ts +4 -0
- package/dist/src/opencode/durable/runner.d.ts.map +1 -1
- package/dist/src/opencode/durable/runner.js +7 -0
- package/dist/src/opencode/durable/runner.js.map +1 -1
- package/dist/src/spi/agent-runner.d.ts +8 -0
- package/dist/src/spi/agent-runner.d.ts.map +1 -1
- package/package.json +1 -1
- package/skills/agent-setup/SKILL.md +9 -2
|
@@ -1,78 +1,19 @@
|
|
|
1
|
-
import { DEFAULT_TEXT_ACTION_PROPOSAL_VOCABULARY,
|
|
1
|
+
import { DEFAULT_TEXT_ACTION_PROPOSAL_VOCABULARY, parseTextActionProposalCommand, } from '../action-proposal-text.js';
|
|
2
2
|
import { AgentService, AttachmentRefusedError } from '../agent-service.js';
|
|
3
|
+
import { onAgentEngine, registeredAgentEngine } from '../durable/agent-run-context.js';
|
|
4
|
+
import { durableExecutor, inlineExecutor, jobId, registerChannelWorkflows, withRetries, } from './executor.js';
|
|
3
5
|
import { ChannelMediaTooLargeError } from './http.js';
|
|
4
6
|
import { toChannelMarkdown } from './markdown.js';
|
|
5
|
-
import {
|
|
7
|
+
import { formatChannelQuestion, parseChannelAnswer } from './questions.js';
|
|
6
8
|
import { splitMessage } from './split.js';
|
|
7
9
|
import { InMemoryChannelStore, lucidChannelStore } from './store.js';
|
|
8
|
-
|
|
9
|
-
const readableSize = (bytes) => bytes >= 1024 * 1024
|
|
10
|
-
? `${Math.round(bytes / (1024 * 1024))} MB`
|
|
11
|
-
: `${Math.max(1, Math.ceil(bytes / 1024))} KB`;
|
|
12
|
-
export const DEFAULT_CHANNEL_TEXTS = {
|
|
13
|
-
approve: 'Confirm',
|
|
14
|
-
reject: 'Cancel',
|
|
15
|
-
proposal: ({ confirmation, toolName }) => confirmation
|
|
16
|
-
? `*${confirmation.title}*${confirmation.detail ? `\n${confirmation.detail}` : ''}`
|
|
17
|
-
: `*Run ${toolName}?*`,
|
|
18
|
-
instruction: ({ approve, reject }) => `Reply *${approve}* to confirm or *${reject}* to cancel.`,
|
|
19
|
-
blockingApproval: 'This action needs an approval that can only be given in the app.',
|
|
20
|
-
failed: 'Sorry, something went wrong. Please try again.',
|
|
21
|
-
actionSucceeded: 'Done.',
|
|
22
|
-
actionFailed: 'The action could not be completed.',
|
|
23
|
-
mediaRefused: (reason, media, limits) => reason === 'disabled'
|
|
24
|
-
? 'I can only read text messages here.'
|
|
25
|
-
: reason === 'size'
|
|
26
|
-
? `That file is too large${limits ? ` (the limit is ${readableSize(limits.maxBytes)})` : ''}.`
|
|
27
|
-
: reason === 'failed'
|
|
28
|
-
? 'I could not download that file. Please send it again.'
|
|
29
|
-
: media.kind === 'audio'
|
|
30
|
-
? 'I cannot listen to audio messages. Please type your message.'
|
|
31
|
-
: `I cannot read this kind of file${media.contentType ? ` (${media.contentType})` : ''}.`,
|
|
32
|
-
questions: DEFAULT_CHANNEL_QUESTION_TEXTS,
|
|
33
|
-
};
|
|
34
|
-
/**
|
|
35
|
-
* Brazilian Portuguese channel texts — the default when the agent's `actionProposalText` is
|
|
36
|
-
* `ptBrActionProposalText`, so the reply words and what the channel says agree.
|
|
37
|
-
*/
|
|
38
|
-
export const ptBrChannelTexts = {
|
|
39
|
-
approve: 'Confirmar',
|
|
40
|
-
reject: 'Cancelar',
|
|
41
|
-
proposal: ({ confirmation, toolName }) => confirmation
|
|
42
|
-
? `*${confirmation.title}*${confirmation.detail ? `\n${confirmation.detail}` : ''}`
|
|
43
|
-
: `*Executar ${toolName}?*`,
|
|
44
|
-
instruction: ({ approve, reject }) => `Responda *${approve}* para confirmar ou *${reject}* para cancelar.`,
|
|
45
|
-
blockingApproval: 'Esta ação precisa de uma aprovação que só pode ser dada no app.',
|
|
46
|
-
failed: 'Desculpe, algo deu errado. Tente de novo, por favor.',
|
|
47
|
-
actionSucceeded: 'Pronto.',
|
|
48
|
-
actionFailed: 'Não foi possível concluir a ação.',
|
|
49
|
-
mediaRefused: (reason, media, limits) => reason === 'disabled'
|
|
50
|
-
? 'Por aqui eu só consigo ler mensagens de texto.'
|
|
51
|
-
: reason === 'size'
|
|
52
|
-
? `Esse arquivo é grande demais${limits ? ` (o limite é ${readableSize(limits.maxBytes)})` : ''}.`
|
|
53
|
-
: reason === 'failed'
|
|
54
|
-
? 'Não consegui baixar esse arquivo. Envie de novo, por favor.'
|
|
55
|
-
: media.kind === 'audio'
|
|
56
|
-
? 'Não consigo ouvir mensagens de áudio. Escreva sua mensagem, por favor.'
|
|
57
|
-
: `Não consigo ler esse tipo de arquivo${media.contentType ? ` (${media.contentType})` : ''}.`,
|
|
58
|
-
questions: ptBrChannelQuestionTexts,
|
|
59
|
-
};
|
|
60
|
-
/**
|
|
61
|
-
* The channel texts that speak the language of the agent's text-decision words: {@link
|
|
62
|
-
* ptBrChannelTexts} for `ptBrActionProposalText` (or any vocabulary whose `language` is Portuguese),
|
|
63
|
-
* else {@link DEFAULT_CHANNEL_TEXTS}.
|
|
64
|
-
*/
|
|
65
|
-
export function channelTextsFor(vocabulary) {
|
|
66
|
-
if (!vocabulary)
|
|
67
|
-
return DEFAULT_CHANNEL_TEXTS;
|
|
68
|
-
const portuguese = /^pt(-|$)/i.test(vocabulary.language ?? '') ||
|
|
69
|
-
vocabulary.approve === ptBrActionProposalText.vocabulary.approve;
|
|
70
|
-
return portuguese ? ptBrChannelTexts : DEFAULT_CHANNEL_TEXTS;
|
|
71
|
-
}
|
|
10
|
+
import { channelTextsFor, mergeTexts, } from './texts.js';
|
|
72
11
|
const BUTTON_ID = /^agora:(approve|reject):([^\s]+)$/;
|
|
73
12
|
/** The tail of a proposal id a button carries — Telegram's `callback_data` holds 64 bytes. */
|
|
74
13
|
const BUTTON_REF_LENGTH = 32;
|
|
75
14
|
const OUTCOME_POLL_MS = 500;
|
|
15
|
+
/** How often a durable job reading a turn looks for a part of it still waiting for a worker. */
|
|
16
|
+
const DRIVE_POLL_MS = 500;
|
|
76
17
|
/** How long "this outcome was relayed" is remembered. */
|
|
77
18
|
const OUTCOME_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
78
19
|
const buttonRef = (proposalId) => proposalId.slice(-BUTTON_REF_LENGTH);
|
|
@@ -95,76 +36,21 @@ function requestOf(ctx) {
|
|
|
95
36
|
rawBody: ctx.request.raw(),
|
|
96
37
|
};
|
|
97
38
|
}
|
|
98
|
-
/**
|
|
99
|
-
* A verified webhook that carried no message to answer: logged (debug; warn when it looked like a
|
|
100
|
-
* person's message that could not be read) with the event and the reason — never the content — so a
|
|
101
|
-
* silently dropped message can be diagnosed.
|
|
102
|
-
*/
|
|
103
|
-
function logIgnored(ctx, adapter, body) {
|
|
104
|
-
const logger = ctx.logger;
|
|
105
|
-
if (!logger)
|
|
106
|
-
return;
|
|
107
|
-
const ignored = adapter.ignored?.(body) ?? null;
|
|
108
|
-
const event = ignored?.event ??
|
|
109
|
-
(typeof body === 'object' && body !== null ? body.event : undefined);
|
|
110
|
-
const bindings = {
|
|
111
|
-
channel: adapter.name,
|
|
112
|
-
...(typeof event === 'string' ? { event } : {}),
|
|
113
|
-
reason: ignored?.reason ?? 'no message',
|
|
114
|
-
};
|
|
115
|
-
const log = ignored?.unexpected ? logger.warn : logger.debug;
|
|
116
|
-
log?.call(logger, bindings, 'channels: webhook ignored');
|
|
117
|
-
}
|
|
118
39
|
/** `AgentService` from the app's container, for code that runs outside a request. */
|
|
119
40
|
async function containerService() {
|
|
120
41
|
const { default: app } = await import('@adonisjs/core/services/app');
|
|
121
42
|
return (await app.container.make(AgentService));
|
|
122
43
|
}
|
|
123
44
|
const registry = new Map();
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
await adapter.send(conversation, { text: piece });
|
|
134
|
-
}
|
|
135
|
-
/** What an executed proposal tells the person; `null` when it did not execute. */
|
|
136
|
-
function outcomeText(proposal, texts) {
|
|
137
|
-
const status = proposal.execution?.status;
|
|
138
|
-
if (status === 'failed')
|
|
139
|
-
return texts.actionFailed;
|
|
140
|
-
if (status !== 'succeeded')
|
|
141
|
-
return null;
|
|
142
|
-
// What its presentation said (`present` / `emitUi` text), else a plain confirmation.
|
|
143
|
-
return proposal.outcome?.text?.trim() || texts.actionSucceeded;
|
|
144
|
-
}
|
|
145
|
-
/**
|
|
146
|
-
* Relay an executed proposal's outcome to the conversation it was proposed in — once: the handler's
|
|
147
|
-
* own wait and this share a claim in the channel's store. `false` when it was not relayed (no
|
|
148
|
-
* channel recorded, the channel is not registered in this process, not executed, already relayed).
|
|
149
|
-
*/
|
|
150
|
-
async function deliverOutcome(proposal, service) {
|
|
151
|
-
const address = record(proposal.executionContext?.pageContext?.channel);
|
|
152
|
-
const name = typeof address?.name === 'string' ? address.name : undefined;
|
|
153
|
-
const conversation = typeof address?.conversation === 'string' ? address.conversation : undefined;
|
|
154
|
-
const registration = name === undefined ? undefined : registry.get(name);
|
|
155
|
-
if (!registration || conversation === undefined)
|
|
156
|
-
return false;
|
|
157
|
-
return relayOnce(await registration.store(service), registration.adapter, await registration.texts(service), conversation, proposal);
|
|
158
|
-
}
|
|
159
|
-
/** Send an executed proposal's outcome unless it was sent already (by this or another replica). */
|
|
160
|
-
async function relayOnce(store, adapter, texts, conversation, proposal) {
|
|
161
|
-
const text = outcomeText(proposal, texts);
|
|
162
|
-
if (text === null)
|
|
163
|
-
return false;
|
|
164
|
-
if (!(await store.claim(`${adapter.name}:outcome:${proposal.id}`, OUTCOME_TTL_MS)))
|
|
165
|
-
return false;
|
|
166
|
-
await deliverText(adapter, conversation, text);
|
|
167
|
-
return true;
|
|
45
|
+
/** The job runner of the channel registered under `channel` in this process. */
|
|
46
|
+
const runnerFor = (channel) => registry.get(channel)?.run;
|
|
47
|
+
let following = false;
|
|
48
|
+
/** Register the channel workflows on the agent's durable engine, now or once it is wired. */
|
|
49
|
+
function followAgentEngine() {
|
|
50
|
+
if (following)
|
|
51
|
+
return;
|
|
52
|
+
following = true;
|
|
53
|
+
onAgentEngine((engine) => registerChannelWorkflows(engine, runnerFor));
|
|
168
54
|
}
|
|
169
55
|
function record(value) {
|
|
170
56
|
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
@@ -173,45 +59,97 @@ function record(value) {
|
|
|
173
59
|
}
|
|
174
60
|
/** `image/jpeg` → `jpeg`, for a file that arrives without a name. */
|
|
175
61
|
const extensionOf = (contentType) => (contentType.split('/')[1] ?? 'bin').split(/[;+]/)[0] ?? 'bin';
|
|
62
|
+
const replyList = (value) => value === null || value === undefined ? [] : Array.isArray(value) ? value : [value];
|
|
63
|
+
/** The component a frame carries (a live one, or a replayed `ui` event), else `null`. */
|
|
64
|
+
function componentOf(frame, position) {
|
|
65
|
+
if (frame.t === 'component') {
|
|
66
|
+
return {
|
|
67
|
+
id: frame.id ?? `ui:${position}`,
|
|
68
|
+
name: frame.name,
|
|
69
|
+
data: frame.data,
|
|
70
|
+
version: frame.version ?? 1,
|
|
71
|
+
...(frame.fallbackText !== undefined ? { fallbackText: frame.fallbackText } : {}),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
if (frame.t === 'event' && frame.event.kind === 'ui') {
|
|
75
|
+
const { event } = frame;
|
|
76
|
+
return {
|
|
77
|
+
id: event.id,
|
|
78
|
+
name: event.component,
|
|
79
|
+
data: event.props,
|
|
80
|
+
version: event.version ?? 1,
|
|
81
|
+
...(event.fallbackText !== undefined ? { fallbackText: event.fallbackText } : {}),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Keep executing, in this process, what of `runId` waits for a worker (`service.drive`), until
|
|
88
|
+
* stopped. A durable channel job runs inside a worker's tick, and the tick ends only when the job
|
|
89
|
+
* does: a turn the job started with a dispatcher that only persists it — or a delegate that turn
|
|
90
|
+
* started — would wait for the next tick, and the job for the turn, until the read times out.
|
|
91
|
+
*/
|
|
92
|
+
function keepDriving(drive, runId) {
|
|
93
|
+
let stopped = false;
|
|
94
|
+
let timer;
|
|
95
|
+
const pass = async () => {
|
|
96
|
+
await drive(runId).catch(() => { });
|
|
97
|
+
if (stopped)
|
|
98
|
+
return;
|
|
99
|
+
timer = setTimeout(() => void pass(), DRIVE_POLL_MS);
|
|
100
|
+
timer.unref?.();
|
|
101
|
+
};
|
|
102
|
+
void pass();
|
|
103
|
+
return () => {
|
|
104
|
+
stopped = true;
|
|
105
|
+
clearTimeout(timer);
|
|
106
|
+
};
|
|
107
|
+
}
|
|
176
108
|
/**
|
|
177
109
|
* The webhook route for one channel. Each request is verified (`401` when it is not the provider's),
|
|
178
|
-
* parsed, deduplicated by the provider's message id,
|
|
179
|
-
*
|
|
110
|
+
* parsed, deduplicated by the provider's message id, taken (persisted, with a durable engine) and
|
|
111
|
+
* acknowledged with `200`; the message is handled after the response — one at a time per
|
|
112
|
+
* conversation, each phase retried — so a slow model never makes the provider retry. Then:
|
|
180
113
|
*
|
|
181
|
-
* -
|
|
182
|
-
* -
|
|
183
|
-
* a text decision ("yes", "confirm #ID") is answered with its reply instead of starting a turn;
|
|
114
|
+
* - an unknown sender gets `unknownSender`; a known one goes through `beforeTurn`;
|
|
115
|
+
* - an answer to a question the turn asked resumes it;
|
|
184
116
|
* - a press on a proposal's button decides that proposal (`via` = the adapter's name);
|
|
185
|
-
* - a
|
|
186
|
-
*
|
|
187
|
-
* -
|
|
188
|
-
*
|
|
117
|
+
* - a text decision ("yes", "no #ID", a button label) decides a card delivered to THIS conversation
|
|
118
|
+
* — never a proposal made elsewhere (`texts.noPendingConfirmation`);
|
|
119
|
+
* - media are downloaded (`prepareMedia`) and attached, or refused with a text;
|
|
120
|
+
* - the message (`transformInbound`) starts a turn with the channel's capabilities;
|
|
121
|
+
* - the reply is converted to the channel's markdown, split at its length limit, and sent — each
|
|
122
|
+
* message once, even when the work is retried or resumed after a crash;
|
|
123
|
+
* - a proposal the turn left pending is sent with Confirm/Cancel buttons (or a text instruction);
|
|
189
124
|
* - an approved proposal's outcome is relayed once it executed (see `outcomeTimeoutMs`, and
|
|
190
125
|
* `channels.onSettled` for one that runs later).
|
|
191
126
|
*
|
|
192
127
|
* Wants `actionApprovalMode: 'independent'`: a blocking approval holds the turn open until someone
|
|
193
128
|
* decides it in the app, so the channel sends what it has and `texts.blockingApproval`.
|
|
194
129
|
*
|
|
195
|
-
* Registers the adapter under its `name` for `channels.onSettled` — names must
|
|
130
|
+
* Registers the adapter under its `name` for `channels.onSettled` and the durable jobs — names must
|
|
131
|
+
* be unique, and a process that resumes durable channel jobs must create the same handlers at boot.
|
|
196
132
|
*/
|
|
197
133
|
export function handleChannel(adapter, options) {
|
|
198
|
-
let resolvedTexts;
|
|
199
|
-
/** The texts, over the defaults in the language of the service's vocabulary — chosen once. */
|
|
200
|
-
const textsFor = (service) => {
|
|
201
|
-
if (resolvedTexts)
|
|
202
|
-
return resolvedTexts;
|
|
203
|
-
const texts = mergeTexts(options.texts, channelTextsFor(service?.actionProposalVocabulary?.() ?? null));
|
|
204
|
-
if (service)
|
|
205
|
-
resolvedTexts = texts;
|
|
206
|
-
return texts;
|
|
207
|
-
};
|
|
208
134
|
const dedupeTtlMs = options.dedupeTtlMs ?? 24 * 60 * 60 * 1000;
|
|
209
135
|
const timeoutMs = options.timeoutMs ?? 5 * 60 * 1000;
|
|
210
136
|
const outcomeTimeoutMs = options.outcomeTimeoutMs ?? 60_000;
|
|
211
137
|
const questionTimeoutMs = options.questionTimeoutMs ?? 30 * 60 * 1000;
|
|
212
138
|
const { capabilities } = adapter;
|
|
213
139
|
const buttons = (capabilities.buttons ?? 0) >= 2;
|
|
214
|
-
const
|
|
140
|
+
const name = adapter.name;
|
|
141
|
+
let resolvedService;
|
|
142
|
+
const serviceFor = (given) => {
|
|
143
|
+
if (options.service)
|
|
144
|
+
return Promise.resolve(options.service);
|
|
145
|
+
if (given)
|
|
146
|
+
return Promise.resolve(given);
|
|
147
|
+
resolvedService ??= containerService().catch((error) => {
|
|
148
|
+
resolvedService = undefined;
|
|
149
|
+
throw error;
|
|
150
|
+
});
|
|
151
|
+
return resolvedService;
|
|
152
|
+
};
|
|
215
153
|
let resolvedStore;
|
|
216
154
|
/** The configured store, else Lucid over the agent store's database, else memory — chosen once. */
|
|
217
155
|
const storeFor = (service) => {
|
|
@@ -224,34 +162,127 @@ export function handleChannel(adapter, options) {
|
|
|
224
162
|
})();
|
|
225
163
|
return resolvedStore;
|
|
226
164
|
};
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
const questionKey = (conversation) => `${
|
|
234
|
-
const cardsKey = (conversation) => `${
|
|
235
|
-
const
|
|
165
|
+
/** The texts for one context, over the defaults in the language of the service's vocabulary. */
|
|
166
|
+
const textsFor = async (service, context) => {
|
|
167
|
+
const base = channelTextsFor(service.actionProposalVocabulary?.() ?? null);
|
|
168
|
+
const overrides = typeof options.texts === 'function' ? await options.texts(context) : options.texts;
|
|
169
|
+
return mergeTexts(overrides, base);
|
|
170
|
+
};
|
|
171
|
+
const questionKey = (conversation) => `${name}:question:${conversation}`;
|
|
172
|
+
const cardsKey = (conversation) => `${name}:cards:${conversation}`;
|
|
173
|
+
const answeredKey = (runId, toolCallId) => `${name}:answered:${runId}:${toolCallId}`;
|
|
174
|
+
/**
|
|
175
|
+
* Settle a question once — by its answer, by its timeout, or skipped: `true` when `owner` settles
|
|
176
|
+
* it (now, or in an earlier run of the same phase), `false` when something else did first.
|
|
177
|
+
*/
|
|
178
|
+
const settleQuestion = async (store, runId, toolCallId, owner) => {
|
|
179
|
+
const key = answeredKey(runId, toolCallId);
|
|
180
|
+
if (await store.claim(key, dedupeTtlMs)) {
|
|
181
|
+
await store.set(key, owner, dedupeTtlMs);
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
return (await store.get(key)) === owner;
|
|
185
|
+
};
|
|
186
|
+
/**
|
|
187
|
+
* Sends to one conversation, each message at most once: every message takes a numbered slot
|
|
188
|
+
* under `key` in the store before it goes out, so a phase that runs again (a retry, a crash
|
|
189
|
+
* recovery) skips what already went. A failed send frees its slot, so the retry sends it.
|
|
190
|
+
*/
|
|
191
|
+
const outbox = (store, key, conversation, who) => {
|
|
192
|
+
let next = 0;
|
|
193
|
+
const send = async (outbound, kind) => {
|
|
194
|
+
const slot = `${key}:${next++}`;
|
|
195
|
+
if (!(await store.claim(slot, dedupeTtlMs)))
|
|
196
|
+
return;
|
|
197
|
+
try {
|
|
198
|
+
if (options.canDeliver &&
|
|
199
|
+
!(await options.canDeliver({
|
|
200
|
+
channel: name,
|
|
201
|
+
conversation,
|
|
202
|
+
outbound,
|
|
203
|
+
kind,
|
|
204
|
+
actor: who.actor,
|
|
205
|
+
actorRef: who.actorRef ?? who.actor?.id ?? null,
|
|
206
|
+
message: who.message,
|
|
207
|
+
proposal: who.proposal ?? null,
|
|
208
|
+
})))
|
|
209
|
+
return;
|
|
210
|
+
await adapter.send(conversation, outbound);
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
await Promise.resolve(store.delete(slot)).catch(() => { });
|
|
214
|
+
throw error;
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
/** Text in the model's markdown: converted, split. */
|
|
218
|
+
const text = async (value, kind) => {
|
|
219
|
+
for (const piece of splitMessage(toChannelMarkdown(value, capabilities.markdown), capabilities.maxLength))
|
|
220
|
+
await send({ text: piece }, kind);
|
|
221
|
+
};
|
|
222
|
+
/** A hook's reply. */
|
|
223
|
+
const reply = async (value, kind) => {
|
|
224
|
+
if (typeof value === 'string')
|
|
225
|
+
return text(value, kind);
|
|
226
|
+
if ('media' in value) {
|
|
227
|
+
const caption = value.caption ?? '';
|
|
228
|
+
if (capabilities.media === true) {
|
|
229
|
+
const max = capabilities.maxCaptionLength ?? capabilities.maxLength;
|
|
230
|
+
return send({
|
|
231
|
+
text: toChannelMarkdown(caption, capabilities.markdown).slice(0, max),
|
|
232
|
+
media: value.media,
|
|
233
|
+
}, kind);
|
|
234
|
+
}
|
|
235
|
+
const fallback = value.fallbackText ?? caption;
|
|
236
|
+
if (fallback.trim() !== '')
|
|
237
|
+
return text(fallback, kind);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (value.raw === true) {
|
|
241
|
+
for (const piece of splitMessage(value.text, capabilities.maxLength))
|
|
242
|
+
await send({ text: piece }, kind);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
return text(value.text, kind);
|
|
246
|
+
};
|
|
247
|
+
return { send, text, reply };
|
|
248
|
+
};
|
|
249
|
+
const commandsFor = (service, proposalId) => {
|
|
236
250
|
const vocabulary = service.actionProposalVocabulary?.() ?? DEFAULT_TEXT_ACTION_PROPOSAL_VOCABULARY;
|
|
237
|
-
const suffix =
|
|
251
|
+
const suffix = proposalId !== null ? ` #${proposalId}` : '';
|
|
238
252
|
return {
|
|
239
253
|
approve: `${vocabulary.approve[0] ?? 'yes'}${suffix}`,
|
|
240
254
|
reject: `${vocabulary.reject[0] ?? 'no'}${suffix}`,
|
|
241
255
|
};
|
|
242
256
|
};
|
|
243
|
-
|
|
244
|
-
|
|
257
|
+
/** Remember a proposal card sent to `conversation` — the only proposals it can decide. */
|
|
258
|
+
const rememberCard = async (store, conversation, proposalId) => {
|
|
259
|
+
const key = cardsKey(conversation);
|
|
260
|
+
const saved = await store.get(key);
|
|
261
|
+
const ref = buttonRef(proposalId);
|
|
262
|
+
const refs = saved === null ? [] : JSON.parse(saved);
|
|
263
|
+
const next = [...refs.filter((known) => known !== ref), ref].slice(-MAX_CARDS);
|
|
264
|
+
await store.set(key, JSON.stringify(next), CARDS_TTL_MS);
|
|
265
|
+
};
|
|
266
|
+
const cardsOf = async (store, conversation) => {
|
|
267
|
+
const saved = await store.get(cardsKey(conversation));
|
|
268
|
+
return new Set(saved === null ? [] : JSON.parse(saved));
|
|
269
|
+
};
|
|
270
|
+
const sendProposal = async (service, store, texts, out, conversation, proposal, withId) => {
|
|
245
271
|
const summary = texts.proposal(proposal);
|
|
246
|
-
const
|
|
247
|
-
const
|
|
272
|
+
const footer = typeof texts.footer === 'function' ? texts.footer(proposal) : texts.footer;
|
|
273
|
+
const instruction = texts.instruction(commandsFor(service, withId ? proposal.id : null));
|
|
274
|
+
const asText = toChannelMarkdown([summary, instruction, footer]
|
|
275
|
+
.filter((part) => part !== undefined && part !== '')
|
|
276
|
+
.join('\n\n'), capabilities.markdown);
|
|
277
|
+
// Remembered before it goes: a "yes" can only decide a card this conversation was sent.
|
|
278
|
+
await rememberCard(store, conversation, proposal.id);
|
|
248
279
|
if (!buttons) {
|
|
249
280
|
for (const piece of splitMessage(asText, capabilities.maxLength))
|
|
250
|
-
await
|
|
281
|
+
await out.send({ text: piece }, 'card');
|
|
251
282
|
return;
|
|
252
283
|
}
|
|
253
284
|
const ids = proposalButtonIds(proposal.id);
|
|
254
|
-
|
|
285
|
+
await out.send({
|
|
255
286
|
text: toChannelMarkdown(summary, capabilities.markdown).slice(0, capabilities.maxLength),
|
|
256
287
|
buttons: [
|
|
257
288
|
{ id: ids.approve, label: texts.approve },
|
|
@@ -259,18 +290,64 @@ export function handleChannel(adapter, options) {
|
|
|
259
290
|
],
|
|
260
291
|
fallbackText: asText.slice(0, capabilities.maxLength),
|
|
261
292
|
instruction: toChannelMarkdown(instruction, capabilities.markdown),
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
293
|
+
...(footer !== undefined && footer !== ''
|
|
294
|
+
? { footer: toChannelMarkdown(footer, capabilities.markdown) }
|
|
295
|
+
: {}),
|
|
296
|
+
}, 'card');
|
|
265
297
|
};
|
|
266
|
-
/**
|
|
267
|
-
const
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
const
|
|
273
|
-
|
|
298
|
+
/** What an executed proposal tells the person, in order; `null` when it did not execute. */
|
|
299
|
+
const outcomeReplies = async (proposal, texts) => {
|
|
300
|
+
const status = proposal.execution?.status;
|
|
301
|
+
if (status !== 'failed' && status !== 'succeeded')
|
|
302
|
+
return null;
|
|
303
|
+
// What its presentation said (`present` / `emitUi` text), else a plain confirmation.
|
|
304
|
+
const text = status === 'failed'
|
|
305
|
+
? texts.actionFailed
|
|
306
|
+
: proposal.outcome?.text?.trim() || texts.actionSucceeded;
|
|
307
|
+
const formatted = await options.formatOutcome?.(proposal, { text, texts });
|
|
308
|
+
return formatted ?? [text];
|
|
309
|
+
};
|
|
310
|
+
/** Send an executed proposal's outcome unless it was sent already (by this or another replica). */
|
|
311
|
+
const relay = async (proposal, conversation, given, who) => {
|
|
312
|
+
const service = await serviceFor(given);
|
|
313
|
+
const store = await storeFor(service);
|
|
314
|
+
const status = proposal.execution?.status;
|
|
315
|
+
if (status !== 'failed' && status !== 'succeeded')
|
|
316
|
+
return false;
|
|
317
|
+
if (!(await store.claim(`${name}:outcome:${proposal.id}`, OUTCOME_TTL_MS)))
|
|
318
|
+
return false;
|
|
319
|
+
const texts = await textsFor(service, {
|
|
320
|
+
actor: who?.actor ?? null,
|
|
321
|
+
message: who?.message ?? null,
|
|
322
|
+
proposal,
|
|
323
|
+
});
|
|
324
|
+
const replies = (await outcomeReplies(proposal, texts)) ?? [];
|
|
325
|
+
const out = outbox(store, `${name}:out:outcome:${proposal.id}`, conversation, {
|
|
326
|
+
actor: who?.actor ?? null,
|
|
327
|
+
actorRef: who?.actor?.id ?? proposal.actorRef ?? null,
|
|
328
|
+
message: who?.message ?? null,
|
|
329
|
+
proposal,
|
|
330
|
+
});
|
|
331
|
+
for (const reply of replies)
|
|
332
|
+
await out.reply(reply, 'outcome');
|
|
333
|
+
return true;
|
|
334
|
+
};
|
|
335
|
+
/** Wait for an approved proposal to run, then relay what it said (or that it failed). */
|
|
336
|
+
const relayOutcome = async (service, actor, threadId, proposalId, message) => {
|
|
337
|
+
if (outcomeTimeoutMs <= 0 || !service.listActionProposals)
|
|
338
|
+
return;
|
|
339
|
+
const deadline = Date.now() + outcomeTimeoutMs;
|
|
340
|
+
while (Date.now() < deadline) {
|
|
341
|
+
const current = (await service.listActionProposals(actor, threadId)).find((proposal) => proposal.id === proposalId);
|
|
342
|
+
if (current?.decision !== 'approved')
|
|
343
|
+
return;
|
|
344
|
+
const status = current.execution?.status;
|
|
345
|
+
if (status === 'succeeded' || status === 'failed') {
|
|
346
|
+
await relay(current, message.conversation, service, { actor, message });
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
await new Promise((resolve) => setTimeout(resolve, OUTCOME_POLL_MS));
|
|
350
|
+
}
|
|
274
351
|
};
|
|
275
352
|
/**
|
|
276
353
|
* A button press the provider forwarded without its id — only the label (Whatsmiau): the id of
|
|
@@ -278,12 +355,11 @@ export function handleChannel(adapter, options) {
|
|
|
278
355
|
* pending. Several such cards → `undefined`: never guess; the label then goes on as a text
|
|
279
356
|
* decision, which asks for the `#id`.
|
|
280
357
|
*/
|
|
281
|
-
const recoverButtonId = async (service, store, actor, threadId, message) => {
|
|
358
|
+
const recoverButtonId = async (service, store, texts, actor, threadId, message) => {
|
|
282
359
|
if (message.buttonId !== undefined || message.buttonWithoutId !== true)
|
|
283
360
|
return undefined;
|
|
284
361
|
if (threadId === null || !service.listActionProposals)
|
|
285
362
|
return undefined;
|
|
286
|
-
const texts = textsFor(service);
|
|
287
363
|
const label = foldLabel(message.text);
|
|
288
364
|
const action = label === foldLabel(texts.approve)
|
|
289
365
|
? 'approve'
|
|
@@ -292,33 +368,24 @@ export function handleChannel(adapter, options) {
|
|
|
292
368
|
: undefined;
|
|
293
369
|
if (action === undefined)
|
|
294
370
|
return undefined;
|
|
295
|
-
const
|
|
296
|
-
if (
|
|
371
|
+
const refs = await cardsOf(store, message.conversation);
|
|
372
|
+
if (refs.size === 0)
|
|
297
373
|
return undefined;
|
|
298
|
-
const refs = new Set(JSON.parse(saved));
|
|
299
374
|
const live = (await service.listActionProposals(actor, threadId)).filter((proposal) => proposal.decision === 'pending' && refs.has(buttonRef(proposal.id)));
|
|
300
375
|
const only = live.length === 1 ? live[0] : undefined;
|
|
301
376
|
return only === undefined ? undefined : `agora:${action}:${buttonRef(only.id)}`;
|
|
302
377
|
};
|
|
303
|
-
/**
|
|
304
|
-
const
|
|
305
|
-
if (
|
|
378
|
+
/** Decide one proposal by a press or a text, answer, and relay its outcome when it ran. */
|
|
379
|
+
const decide = async (service, out, actor, threadId, proposalId, decision, remember, message) => {
|
|
380
|
+
if (!service.decideActionProposal)
|
|
306
381
|
return;
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
return;
|
|
312
|
-
const texts = textsFor(service);
|
|
313
|
-
if (outcomeText(current, texts) !== null) {
|
|
314
|
-
await relayOnce(await storeFor(service), adapter, texts, conversation, current);
|
|
315
|
-
return;
|
|
316
|
-
}
|
|
317
|
-
await new Promise((resolve) => setTimeout(resolve, OUTCOME_POLL_MS));
|
|
318
|
-
}
|
|
382
|
+
const result = await service.decideActionProposal(actor, threadId, proposalId, decision, remember ? { remember: true } : {}, name);
|
|
383
|
+
await out.text(service.actionProposalReply(result, decision), 'notice');
|
|
384
|
+
if (result.status === 'applied' && decision === 'approved')
|
|
385
|
+
await relayOutcome(service, actor, threadId, proposalId, message);
|
|
319
386
|
};
|
|
320
387
|
/** A press on one of our proposal buttons: decide it. `false` → not one of ours. */
|
|
321
|
-
const pressButton = async (service, actor, threadId, message) => {
|
|
388
|
+
const pressButton = async (service, out, actor, threadId, message) => {
|
|
322
389
|
const match = message.buttonId === undefined ? null : BUTTON_ID.exec(message.buttonId);
|
|
323
390
|
if (!match || !service.decideActionProposal || !service.listActionProposals)
|
|
324
391
|
return false;
|
|
@@ -328,68 +395,144 @@ export function handleChannel(adapter, options) {
|
|
|
328
395
|
? undefined
|
|
329
396
|
: (await service.listActionProposals(actor, threadId)).find((candidate) => buttonRef(candidate.id) === ref);
|
|
330
397
|
if (threadId === null || !proposal) {
|
|
331
|
-
await
|
|
398
|
+
await out.text(service.actionProposalReply({ status: 'not_found' }, decision), 'notice');
|
|
332
399
|
return true;
|
|
333
400
|
}
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
401
|
+
await decide(service, out, actor, threadId, proposal.id, decision, false, message);
|
|
402
|
+
return true;
|
|
403
|
+
};
|
|
404
|
+
/**
|
|
405
|
+
* A text decision ("yes", "no #ID", a button label the provider forwarded alone), scoped to the
|
|
406
|
+
* cards this conversation was sent. `false` → not a decision: an ordinary message.
|
|
407
|
+
*/
|
|
408
|
+
const decideByText = async (service, store, texts, out, actor, threadId, message, text) => {
|
|
409
|
+
if (threadId === null || !service.listActionProposals || !service.decideActionProposal)
|
|
410
|
+
return false;
|
|
411
|
+
const vocabulary = service.actionProposalVocabulary?.() ?? DEFAULT_TEXT_ACTION_PROPOSAL_VOCABULARY;
|
|
412
|
+
const command = parseTextActionProposalCommand(text, vocabulary);
|
|
413
|
+
if (command.status !== 'command')
|
|
414
|
+
return false;
|
|
415
|
+
const proposals = await service.listActionProposals(actor, threadId);
|
|
416
|
+
const cards = await cardsOf(store, message.conversation);
|
|
417
|
+
const deliverable = proposals.filter((proposal) => proposal.decision === 'pending' && cards.has(buttonRef(proposal.id)));
|
|
418
|
+
const named = command.proposalId;
|
|
419
|
+
if (named !== undefined) {
|
|
420
|
+
const target = deliverable.find((proposal) => proposal.id === named);
|
|
421
|
+
// An `#ID` naming no proposal of the thread is an ordinary message.
|
|
422
|
+
if (target === undefined && !proposals.some((proposal) => proposal.id === named))
|
|
423
|
+
return false;
|
|
424
|
+
if (target === undefined) {
|
|
425
|
+
await out.text(texts.noPendingConfirmation, 'notice');
|
|
426
|
+
return true;
|
|
427
|
+
}
|
|
428
|
+
if (command.remember && options.allowRemember === false) {
|
|
429
|
+
await out.text(texts.rememberRefused, 'notice');
|
|
430
|
+
return true;
|
|
431
|
+
}
|
|
432
|
+
await decide(service, out, actor, threadId, target.id, command.decision, command.remember, message);
|
|
433
|
+
return true;
|
|
434
|
+
}
|
|
435
|
+
if (deliverable.length === 0) {
|
|
436
|
+
// Nothing pending anywhere: "yes" is just a word in the conversation.
|
|
437
|
+
if (!proposals.some((proposal) => proposal.decision === 'pending'))
|
|
438
|
+
return false;
|
|
439
|
+
await out.text(texts.noPendingConfirmation, 'notice');
|
|
440
|
+
return true;
|
|
441
|
+
}
|
|
442
|
+
if (deliverable.length > 1) {
|
|
443
|
+
await out.text(texts.ambiguousDecision(deliverable.map((proposal) => proposal.id), commandsFor(service, null)), 'notice');
|
|
444
|
+
return true;
|
|
445
|
+
}
|
|
446
|
+
if (command.remember && options.allowRemember === false) {
|
|
447
|
+
await out.text(texts.rememberRefused, 'notice');
|
|
448
|
+
return true;
|
|
449
|
+
}
|
|
450
|
+
const [only] = deliverable;
|
|
451
|
+
if (only === undefined)
|
|
452
|
+
return false;
|
|
453
|
+
await decide(service, out, actor, threadId, only.id, command.decision, command.remember, message);
|
|
338
454
|
return true;
|
|
339
455
|
};
|
|
340
|
-
const askNext = (
|
|
456
|
+
const askNext = (texts, out, pending) => {
|
|
341
457
|
const question = pending.questions[pending.index];
|
|
342
458
|
if (!question)
|
|
343
459
|
return Promise.resolve();
|
|
344
|
-
return
|
|
460
|
+
return out.text(formatChannelQuestion(question, {
|
|
345
461
|
index: pending.index,
|
|
346
462
|
total: pending.questions.length,
|
|
347
463
|
...(pending.preamble !== undefined ? { preamble: pending.preamble } : {}),
|
|
348
|
-
},
|
|
464
|
+
}, texts.questions), 'question');
|
|
349
465
|
};
|
|
350
|
-
/**
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
if (parsed.status === 'invalid') {
|
|
358
|
-
await deliver(message.conversation, texts.questions.invalid(parsed.problem));
|
|
359
|
-
await askNext(service, message.conversation, pending);
|
|
360
|
-
return;
|
|
361
|
-
}
|
|
362
|
-
const next = {
|
|
363
|
-
...pending,
|
|
364
|
-
index: pending.index + 1,
|
|
365
|
-
answers: parsed.status === 'answer'
|
|
366
|
-
? { ...pending.answers, [question.id]: parsed.values }
|
|
367
|
-
: pending.answers,
|
|
368
|
-
};
|
|
466
|
+
/**
|
|
467
|
+
* The person's message answers the question in front of them; the last one resumes the run.
|
|
468
|
+
* Returns the run to read on, once all are answered.
|
|
469
|
+
*/
|
|
470
|
+
const answerQuestion = async (service, store, texts, out, actor, message, pending) => {
|
|
471
|
+
if (!service.answer)
|
|
472
|
+
return null;
|
|
369
473
|
const key = questionKey(message.conversation);
|
|
370
|
-
|
|
474
|
+
// This message's answer was applied already (its phase is running again): carry on from there.
|
|
475
|
+
const applied = pending.lastMessageId === message.id;
|
|
476
|
+
let next = pending;
|
|
477
|
+
if (!applied) {
|
|
478
|
+
const question = pending.questions[pending.index];
|
|
479
|
+
if (!question)
|
|
480
|
+
return null;
|
|
481
|
+
const parsed = parseChannelAnswer(question, message.text, texts.questions.skipWord);
|
|
482
|
+
if (parsed.status === 'invalid') {
|
|
483
|
+
await out.text(texts.questions.invalid(parsed.problem), 'question');
|
|
484
|
+
await askNext(texts, out, pending);
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
next = {
|
|
488
|
+
...pending,
|
|
489
|
+
index: pending.index + 1,
|
|
490
|
+
answers: parsed.status === 'answer'
|
|
491
|
+
? { ...pending.answers, [question.id]: parsed.values }
|
|
492
|
+
: pending.answers,
|
|
493
|
+
lastMessageId: message.id,
|
|
494
|
+
};
|
|
371
495
|
await store.set(key, JSON.stringify(next), questionTimeoutMs);
|
|
372
|
-
|
|
373
|
-
|
|
496
|
+
}
|
|
497
|
+
if (next.index < next.questions.length) {
|
|
498
|
+
await askNext(texts, out, next);
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
501
|
+
if (!(await settleQuestion(store, next.runId, next.toolCallId, `answer:${message.id}`))) {
|
|
502
|
+
// It timed out while this answer was on its way: the run went on without it, and this is a
|
|
503
|
+
// message like any other.
|
|
504
|
+
await store.delete(key);
|
|
505
|
+
return 'expired';
|
|
506
|
+
}
|
|
507
|
+
// The run that asked goes on: what it says next is read from its stream.
|
|
508
|
+
try {
|
|
509
|
+
await service.answer({
|
|
510
|
+
runId: next.runId,
|
|
511
|
+
toolCallId: next.toolCallId,
|
|
512
|
+
answers: next.answers,
|
|
513
|
+
answeredByRef: actor.id,
|
|
514
|
+
answeredVia: name,
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
catch (error) {
|
|
518
|
+
// Taken by a run of this phase that died after answering: the run has it.
|
|
519
|
+
if (!applied)
|
|
520
|
+
throw error;
|
|
374
521
|
}
|
|
375
522
|
await store.delete(key);
|
|
376
|
-
|
|
377
|
-
await service.answer({
|
|
378
|
-
runId: next.runId,
|
|
379
|
-
toolCallId: next.toolCallId,
|
|
380
|
-
answers: next.answers,
|
|
381
|
-
answeredByRef: actor.id,
|
|
382
|
-
answeredVia: adapter.name,
|
|
383
|
-
});
|
|
523
|
+
return next.streamRunId;
|
|
384
524
|
};
|
|
385
525
|
/** Download a message's files and stage them for the turn; refuse what cannot be attached. */
|
|
386
|
-
const attachMedia = async (service, actor, message) => {
|
|
526
|
+
const attachMedia = async (service, texts, out, actor, message) => {
|
|
387
527
|
const refs = [];
|
|
388
|
-
const
|
|
389
|
-
const
|
|
528
|
+
const extra = [];
|
|
529
|
+
const base = service.attachmentLimits?.() ?? null;
|
|
390
530
|
for (const media of message.media ?? []) {
|
|
391
|
-
const
|
|
392
|
-
|
|
531
|
+
const limits = options.mediaLimits ? options.mediaLimits(base, media) : base;
|
|
532
|
+
const refuse = (reason) => out.text(texts.mediaRefused(reason, media, limits), 'notice');
|
|
533
|
+
if (limits === null ||
|
|
534
|
+
!adapter.download ||
|
|
535
|
+
(!service.stageAttachment && !options.prepareMedia)) {
|
|
393
536
|
await refuse('disabled');
|
|
394
537
|
continue;
|
|
395
538
|
}
|
|
@@ -403,7 +546,28 @@ export function handleChannel(adapter, options) {
|
|
|
403
546
|
continue;
|
|
404
547
|
}
|
|
405
548
|
try {
|
|
406
|
-
|
|
549
|
+
let file = await adapter.download(media, { maxBytes: limits.maxBytes });
|
|
550
|
+
const prepared = await options.prepareMedia?.(file, media, {
|
|
551
|
+
channel: name,
|
|
552
|
+
conversation: message.conversation,
|
|
553
|
+
message,
|
|
554
|
+
actor,
|
|
555
|
+
});
|
|
556
|
+
if (prepared && 'refuse' in prepared) {
|
|
557
|
+
await refuse(prepared.refuse);
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
if (prepared && 'text' in prepared) {
|
|
561
|
+
if (prepared.text.trim() !== '')
|
|
562
|
+
extra.push(prepared.text);
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
if (prepared && 'file' in prepared)
|
|
566
|
+
file = prepared.file;
|
|
567
|
+
if (!service.stageAttachment) {
|
|
568
|
+
await refuse('disabled');
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
407
571
|
const attachment = await service.stageAttachment(actor, {
|
|
408
572
|
data: file.data,
|
|
409
573
|
contentType: file.contentType,
|
|
@@ -422,71 +586,91 @@ export function handleChannel(adapter, options) {
|
|
|
422
586
|
}
|
|
423
587
|
}
|
|
424
588
|
}
|
|
425
|
-
return refs;
|
|
589
|
+
return { refs, extra };
|
|
426
590
|
};
|
|
427
|
-
/**
|
|
428
|
-
|
|
591
|
+
/**
|
|
592
|
+
* Read a run's frames until it ends (or parks on a question, or on something a text channel
|
|
593
|
+
* cannot settle) and deliver them. Reads the stream from its start: what was delivered before (a
|
|
594
|
+
* read before a question, a read a crash cut short) is skipped by its slot.
|
|
595
|
+
*/
|
|
596
|
+
const readTurn = async (service, store, actor, runId, threadId, conversation, message) => {
|
|
597
|
+
const texts = await textsFor(service, { actor, message });
|
|
598
|
+
const out = outbox(store, `${name}:out:${runId}`, conversation, { actor, message });
|
|
429
599
|
const parts = [];
|
|
430
600
|
const proposals = new Map();
|
|
601
|
+
/** Components rendered as files, by id (a later one replaces), sent before the text. */
|
|
602
|
+
const rendered = new Map();
|
|
603
|
+
const asText = new Set();
|
|
604
|
+
let wroteText = false;
|
|
431
605
|
let failed = false;
|
|
432
606
|
let blocked = false;
|
|
433
|
-
let
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
const flush = async () => {
|
|
607
|
+
let parked = false;
|
|
608
|
+
let position = 0;
|
|
609
|
+
const deadlineAt = Date.now() + timeoutMs;
|
|
610
|
+
const flush = async (end) => {
|
|
611
|
+
const batch = [...rendered.values()];
|
|
612
|
+
rendered.clear();
|
|
613
|
+
for (const { replies } of batch)
|
|
614
|
+
for (const reply of replies)
|
|
615
|
+
await out.reply(reply, 'reply');
|
|
437
616
|
const text = parts.join('').trim();
|
|
438
617
|
parts.length = 0;
|
|
439
618
|
if (text !== '')
|
|
440
|
-
await
|
|
619
|
+
await out.text(text, 'reply');
|
|
620
|
+
if (end && !wroteText && batch.length > 0) {
|
|
621
|
+
const after = texts.componentsOnly?.(batch.map(({ component }) => component));
|
|
622
|
+
if (after !== undefined && after !== '')
|
|
623
|
+
await out.text(after, 'reply');
|
|
624
|
+
}
|
|
441
625
|
};
|
|
442
626
|
const stream = service.subscribe(runId)[Symbol.asyncIterator]();
|
|
443
|
-
|
|
627
|
+
const drive = service.drive?.bind(service);
|
|
628
|
+
const stopDriving = executor().durable && drive !== undefined ? keepDriving(drive, runId) : undefined;
|
|
444
629
|
try {
|
|
445
630
|
for (;;) {
|
|
446
631
|
let timer;
|
|
447
632
|
const timeout = new Promise((resolve) => {
|
|
448
633
|
timer = setTimeout(() => resolve('timeout'), Math.max(0, deadlineAt - Date.now()));
|
|
449
634
|
});
|
|
450
|
-
|
|
451
|
-
reading ??= stream.next();
|
|
452
|
-
const next = await Promise.race([reading, timeout]).finally(() => clearTimeout(timer));
|
|
453
|
-
if (next !== 'timeout')
|
|
454
|
-
reading = undefined;
|
|
635
|
+
const next = await Promise.race([stream.next(), timeout]).finally(() => clearTimeout(timer));
|
|
455
636
|
if (next === 'timeout') {
|
|
456
|
-
|
|
457
|
-
// Nobody answered: the agent goes on on its own assumptions.
|
|
458
|
-
const { runId: parkedRun, toolCallId } = asking;
|
|
459
|
-
asking = null;
|
|
460
|
-
await store.delete(questionKey(conversation));
|
|
461
|
-
await service
|
|
462
|
-
.skip({
|
|
463
|
-
runId: parkedRun,
|
|
464
|
-
toolCallId,
|
|
465
|
-
answeredByRef: actor.id,
|
|
466
|
-
answeredVia: adapter.name,
|
|
467
|
-
})
|
|
468
|
-
.catch(() => { });
|
|
469
|
-
deadlineAt = Date.now() + timeoutMs;
|
|
470
|
-
continue;
|
|
471
|
-
}
|
|
472
|
-
failed = parts.length === 0;
|
|
637
|
+
failed = parts.length === 0 && rendered.size === 0 && !wroteText;
|
|
473
638
|
await service.cancel(runId).catch(() => { });
|
|
474
639
|
break;
|
|
475
640
|
}
|
|
476
641
|
if (next.done)
|
|
477
642
|
break;
|
|
478
643
|
const frame = next.value;
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
644
|
+
const component = componentOf(frame, position++);
|
|
645
|
+
if (component !== null) {
|
|
646
|
+
const replies = options.renderComponent
|
|
647
|
+
? replyList(await options.renderComponent(component, {
|
|
648
|
+
actor,
|
|
649
|
+
conversation,
|
|
650
|
+
runId,
|
|
651
|
+
rendered: rendered.size,
|
|
652
|
+
}))
|
|
653
|
+
: [];
|
|
654
|
+
if (replies.length > 0) {
|
|
655
|
+
rendered.set(component.id, { component, replies });
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
658
|
+
rendered.delete(component.id);
|
|
659
|
+
if (component.fallbackText && !asText.has(component.id)) {
|
|
660
|
+
// Only a component the turn negotiated as drawable comes as a frame; its text is all a
|
|
661
|
+
// channel that does not render it can show.
|
|
662
|
+
asText.add(component.id);
|
|
663
|
+
wroteText = true;
|
|
664
|
+
parts.push(`\n\n${component.fallbackText}\n\n`);
|
|
665
|
+
}
|
|
483
666
|
}
|
|
484
|
-
if (frame.t === 'text')
|
|
667
|
+
else if (frame.t === 'text') {
|
|
668
|
+
wroteText ||= frame.v.trim() !== '';
|
|
485
669
|
parts.push(frame.v);
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
parts.push(
|
|
670
|
+
}
|
|
671
|
+
else if (frame.t === 'event' && frame.event.kind === 'text') {
|
|
672
|
+
wroteText ||= frame.event.text.trim() !== '';
|
|
673
|
+
parts.push(frame.event.text);
|
|
490
674
|
}
|
|
491
675
|
else if (frame.t === 'approval') {
|
|
492
676
|
if (frame.target?.kind === 'proposal') {
|
|
@@ -504,173 +688,457 @@ export function handleChannel(adapter, options) {
|
|
|
504
688
|
}
|
|
505
689
|
else if (frame.t === 'elicitation') {
|
|
506
690
|
const questions = frame.request.questions;
|
|
691
|
+
const asked = {
|
|
692
|
+
threadId,
|
|
693
|
+
runId: frame.runId,
|
|
694
|
+
streamRunId: runId,
|
|
695
|
+
toolCallId: frame.id,
|
|
696
|
+
...(frame.request.preamble !== undefined ? { preamble: frame.request.preamble } : {}),
|
|
697
|
+
questions,
|
|
698
|
+
index: 0,
|
|
699
|
+
answers: {},
|
|
700
|
+
};
|
|
701
|
+
if ((await store.get(answeredKey(frame.runId, frame.id))) !== null) {
|
|
702
|
+
// Answered (or skipped) before, and the run went on past it. What was sent around it
|
|
703
|
+
// then is sent (skipped, by its slots) the same way now, so what follows keeps its slots.
|
|
704
|
+
if (service.answer && questions.length > 0) {
|
|
705
|
+
await flush(false);
|
|
706
|
+
await askNext(texts, out, asked);
|
|
707
|
+
}
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
507
710
|
if (!service.answer || questions.length === 0) {
|
|
711
|
+
await settleQuestion(store, frame.runId, frame.id, 'skipped');
|
|
508
712
|
await service
|
|
509
713
|
.skip({
|
|
510
714
|
runId: frame.runId,
|
|
511
715
|
toolCallId: frame.id,
|
|
512
716
|
answeredByRef: actor.id,
|
|
513
|
-
answeredVia:
|
|
717
|
+
answeredVia: name,
|
|
514
718
|
})
|
|
515
719
|
.catch(() => { });
|
|
516
720
|
continue;
|
|
517
721
|
}
|
|
518
|
-
await flush();
|
|
519
|
-
const pending =
|
|
520
|
-
|
|
722
|
+
await flush(false);
|
|
723
|
+
const pending = asked;
|
|
724
|
+
const key = questionKey(conversation);
|
|
725
|
+
const waiting = await store.get(key);
|
|
726
|
+
const same = waiting !== null && JSON.parse(waiting).toolCallId === frame.id;
|
|
727
|
+
if (!same)
|
|
728
|
+
await store.set(key, JSON.stringify(pending), questionTimeoutMs);
|
|
729
|
+
await askNext(texts, out, pending);
|
|
730
|
+
// The answers come as the next messages; nobody answering in time skips it.
|
|
731
|
+
await executor().later({
|
|
732
|
+
kind: 'timeout',
|
|
733
|
+
channel: name,
|
|
734
|
+
conversation,
|
|
521
735
|
runId: frame.runId,
|
|
522
736
|
toolCallId: frame.id,
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
await askNext(service, conversation, pending);
|
|
530
|
-
asking = { runId: frame.runId, toolCallId: frame.id };
|
|
531
|
-
deadlineAt = Date.now() + questionTimeoutMs;
|
|
737
|
+
streamRunId: runId,
|
|
738
|
+
threadId,
|
|
739
|
+
actor,
|
|
740
|
+
}, jobId(name, 'timeout', frame.runId, frame.id), Date.now() + questionTimeoutMs);
|
|
741
|
+
parked = true;
|
|
742
|
+
break;
|
|
532
743
|
}
|
|
533
|
-
else if (frame.t === 'error')
|
|
744
|
+
else if (frame.t === 'error') {
|
|
534
745
|
failed = true;
|
|
746
|
+
// A failed turn shows none of what it drew before failing.
|
|
747
|
+
rendered.clear();
|
|
748
|
+
}
|
|
535
749
|
}
|
|
536
750
|
}
|
|
537
751
|
finally {
|
|
752
|
+
stopDriving?.();
|
|
538
753
|
void stream.return?.();
|
|
539
754
|
}
|
|
540
|
-
|
|
755
|
+
if (parked)
|
|
756
|
+
return;
|
|
757
|
+
await flush(true);
|
|
758
|
+
if (failed)
|
|
759
|
+
await out.text(texts.failed, 'notice');
|
|
760
|
+
if (blocked)
|
|
761
|
+
await out.text(texts.blockingApproval, 'notice');
|
|
762
|
+
const left = [...proposals.values()];
|
|
763
|
+
for (const proposal of left)
|
|
764
|
+
await sendProposal(service, store, texts, out, conversation, proposal, left.length > 1);
|
|
541
765
|
};
|
|
542
|
-
const
|
|
543
|
-
|
|
544
|
-
|
|
766
|
+
const hookContext = (message) => ({
|
|
767
|
+
channel: name,
|
|
768
|
+
conversation: message.conversation,
|
|
769
|
+
message,
|
|
770
|
+
});
|
|
771
|
+
/** Before the turn: who is talking, and whether the app lets them through. */
|
|
772
|
+
const prepare = async (message) => {
|
|
773
|
+
const service = await serviceFor();
|
|
545
774
|
const store = await storeFor(service);
|
|
546
|
-
|
|
775
|
+
await adapter.acknowledge?.(message).catch(() => { });
|
|
776
|
+
const actor = await options.actor(message);
|
|
547
777
|
if (actor === null) {
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
778
|
+
const out = outbox(store, `${name}:out:msg:${message.id}:sender`, message.conversation, {
|
|
779
|
+
actor: null,
|
|
780
|
+
message,
|
|
781
|
+
});
|
|
782
|
+
const replies = options.unknownSender
|
|
783
|
+
? replyList(await options.unknownSender(message, hookContext(message)))
|
|
784
|
+
: replyList((await textsFor(service, { actor: null, message })).unknownSender);
|
|
785
|
+
for (const reply of replies)
|
|
786
|
+
await out.reply(reply, 'gate');
|
|
787
|
+
return { stop: true };
|
|
788
|
+
}
|
|
789
|
+
const gate = await options.beforeTurn?.(message, actor, hookContext(message));
|
|
790
|
+
if (gate === 'stop')
|
|
791
|
+
return { stop: true };
|
|
792
|
+
if (gate !== undefined && gate !== 'continue') {
|
|
793
|
+
const out = outbox(store, `${name}:out:msg:${message.id}:gate`, message.conversation, {
|
|
794
|
+
actor,
|
|
795
|
+
message,
|
|
796
|
+
});
|
|
797
|
+
for (const reply of 'replies' in gate ? gate.replies : [gate.reply])
|
|
798
|
+
await out.reply(reply, 'gate');
|
|
799
|
+
return { stop: true };
|
|
800
|
+
}
|
|
801
|
+
const threadId = (await options.thread(actor, message)) ?? null;
|
|
802
|
+
return { stop: false, actor, threadId };
|
|
803
|
+
};
|
|
804
|
+
/**
|
|
805
|
+
* The message itself: an answer, a press, a decision, or a turn. Returns the run to read, if any.
|
|
806
|
+
* A turn is started once per message, however often this runs: its run id is kept under the
|
|
807
|
+
* message id before anything else can fail.
|
|
808
|
+
*/
|
|
809
|
+
const act = async (received, actor, threadId) => {
|
|
810
|
+
const service = await serviceFor();
|
|
811
|
+
const store = await storeFor(service);
|
|
812
|
+
const turnKey = `${name}:turn:${received.id}`;
|
|
813
|
+
const started = await store.get(turnKey);
|
|
814
|
+
if (started !== null && started !== '') {
|
|
815
|
+
// This phase ran to its end before (a retry, a recovery): the same outcome.
|
|
816
|
+
return JSON.parse(started);
|
|
551
817
|
}
|
|
552
|
-
|
|
553
|
-
const
|
|
818
|
+
/** What this message came to, kept so a phase that runs again does not do it twice. */
|
|
819
|
+
const done = async (result) => {
|
|
820
|
+
await store.set(turnKey, JSON.stringify(result), dedupeTtlMs);
|
|
821
|
+
return result;
|
|
822
|
+
};
|
|
823
|
+
const texts = await textsFor(service, { actor, message: received });
|
|
824
|
+
const out = outbox(store, `${name}:out:msg:${received.id}`, received.conversation, {
|
|
825
|
+
actor,
|
|
826
|
+
message: received,
|
|
827
|
+
});
|
|
828
|
+
const recovered = await recoverButtonId(service, store, texts, actor, threadId, received);
|
|
554
829
|
const message = recovered === undefined ? received : { ...received, buttonId: recovered };
|
|
555
830
|
const ours = message.buttonId !== undefined && BUTTON_ID.test(message.buttonId);
|
|
556
831
|
if (!ours) {
|
|
557
832
|
const waiting = await store.get(questionKey(message.conversation));
|
|
558
833
|
const pending = waiting === null ? null : JSON.parse(waiting);
|
|
559
834
|
if (pending !== null && (pending.threadId === null || pending.threadId === threadId)) {
|
|
560
|
-
await answerQuestion(service, store, actor, message, pending);
|
|
561
|
-
|
|
835
|
+
const resume = await answerQuestion(service, store, texts, out, actor, message, pending);
|
|
836
|
+
if (resume !== 'expired')
|
|
837
|
+
return done(resume === null ? null : { runId: resume, threadId: pending.threadId });
|
|
562
838
|
}
|
|
563
839
|
}
|
|
564
|
-
if (await pressButton(service, actor, threadId, message))
|
|
565
|
-
return;
|
|
566
|
-
const
|
|
840
|
+
if (await pressButton(service, out, actor, threadId, message))
|
|
841
|
+
return done(null);
|
|
842
|
+
const media = await attachMedia(service, texts, out, actor, message);
|
|
843
|
+
let inbound = {
|
|
844
|
+
text: [message.text, ...media.extra].filter((part) => part.trim() !== '').join('\n\n'),
|
|
845
|
+
attachments: media.refs,
|
|
846
|
+
};
|
|
847
|
+
if (options.transformInbound)
|
|
848
|
+
inbound = await options.transformInbound(inbound, {
|
|
849
|
+
...hookContext(message),
|
|
850
|
+
actor,
|
|
851
|
+
threadId,
|
|
852
|
+
});
|
|
853
|
+
if (inbound.attachments.length === 0 &&
|
|
854
|
+
(await decideByText(service, store, texts, out, actor, threadId, message, inbound.text)))
|
|
855
|
+
return done(null);
|
|
567
856
|
// A file nobody could attach, and no caption: there is nothing left to answer.
|
|
568
|
-
if (
|
|
569
|
-
return;
|
|
857
|
+
if (inbound.text === '' && inbound.attachments.length === 0)
|
|
858
|
+
return done(null);
|
|
859
|
+
if (!(await store.claim(turnKey, dedupeTtlMs))) {
|
|
860
|
+
// Taken by a run of this phase that died between starting the turn and recording it: never
|
|
861
|
+
// start a second turn for one message.
|
|
862
|
+
throw Object.assign(new Error(`[@adonis-agora/agent] channel ${name}: message ${message.id} may have started a turn already; not starting another`), { name: 'ChannelTurnUncertainError', status: 409 });
|
|
863
|
+
}
|
|
570
864
|
const pageContext = typeof options.pageContext === 'function'
|
|
571
865
|
? options.pageContext(message)
|
|
572
|
-
: (options.pageContext ?? { kind:
|
|
573
|
-
const channel = { name
|
|
574
|
-
const
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
866
|
+
: (options.pageContext ?? { kind: name });
|
|
867
|
+
const channel = { name, conversation: message.conversation };
|
|
868
|
+
const sendOptions = { textDecisions: false };
|
|
869
|
+
let sent;
|
|
870
|
+
try {
|
|
871
|
+
sent = await service.send({
|
|
872
|
+
actor,
|
|
873
|
+
message: inbound.text,
|
|
874
|
+
...(threadId !== null ? { threadId } : {}),
|
|
875
|
+
...(options.agentName !== undefined ? { agentName: options.agentName } : {}),
|
|
876
|
+
...(inbound.attachments.length > 0 ? { attachments: inbound.attachments } : {}),
|
|
877
|
+
uiCapabilities: options.uiCapabilities ?? { components: [] },
|
|
878
|
+
pageContext: { ...pageContext, channel },
|
|
879
|
+
hostContext: {
|
|
880
|
+
channel: name,
|
|
881
|
+
conversation: message.conversation,
|
|
882
|
+
messageId: message.id,
|
|
883
|
+
},
|
|
884
|
+
}, sendOptions);
|
|
885
|
+
}
|
|
886
|
+
catch (error) {
|
|
887
|
+
// Nothing started: a retry may start it.
|
|
888
|
+
await Promise.resolve(store.delete(turnKey)).catch(() => { });
|
|
889
|
+
throw error;
|
|
890
|
+
}
|
|
591
891
|
if ('proposalDecision' in sent) {
|
|
592
|
-
|
|
892
|
+
// A service that decides by text itself (one that ignores `textDecisions`).
|
|
893
|
+
await done(null);
|
|
894
|
+
await out.text(sent.text, 'notice');
|
|
593
895
|
const decided = sent.proposalDecision;
|
|
594
896
|
if ('proposal' in decided &&
|
|
595
897
|
decided.status === 'applied' &&
|
|
596
898
|
decided.proposal?.decision === 'approved')
|
|
597
|
-
await relayOutcome(service, actor, sent.threadId, decided.proposal.id, message
|
|
598
|
-
return;
|
|
899
|
+
await relayOutcome(service, actor, sent.threadId, decided.proposal.id, message);
|
|
900
|
+
return null;
|
|
599
901
|
}
|
|
600
902
|
// A queued message starts later under its own id: its answer is read the same way.
|
|
601
903
|
const runId = sent.queued === true ? (sent.runId ?? sent.messageId) : sent.runId;
|
|
602
|
-
const turn = await
|
|
603
|
-
if (
|
|
604
|
-
await
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
904
|
+
const turn = await done({ runId, threadId: sent.threadId });
|
|
905
|
+
if (threadId === null)
|
|
906
|
+
await options.onThreadCreated?.(sent.threadId, actor, message);
|
|
907
|
+
await options.onTurnStarted?.({
|
|
908
|
+
runId,
|
|
909
|
+
threadId: sent.threadId,
|
|
910
|
+
actor,
|
|
911
|
+
message,
|
|
912
|
+
queued: sent.queued === true,
|
|
913
|
+
});
|
|
914
|
+
return turn;
|
|
611
915
|
};
|
|
612
|
-
const
|
|
613
|
-
|
|
614
|
-
|
|
916
|
+
const retry = (fn) => withRetries(fn, options.retry);
|
|
917
|
+
const report = (error, message) => {
|
|
918
|
+
if (options.onError && message !== null)
|
|
919
|
+
options.onError(error, message);
|
|
920
|
+
else
|
|
921
|
+
console.error('[@adonis-agora/agent] Channel message failed', {
|
|
922
|
+
channel: name,
|
|
923
|
+
message: error instanceof Error ? error.message : String(error),
|
|
924
|
+
});
|
|
615
925
|
};
|
|
616
|
-
|
|
617
|
-
|
|
926
|
+
/** One job, phase by phase — each phase a checkpoint under a durable engine. */
|
|
927
|
+
const run = async (job, step) => {
|
|
928
|
+
const message = job.kind === 'message' ? job.message : null;
|
|
929
|
+
try {
|
|
930
|
+
if (job.kind === 'timeout') {
|
|
931
|
+
await step('timeout', () => retry(async () => {
|
|
932
|
+
const service = await serviceFor();
|
|
933
|
+
const store = await storeFor(service);
|
|
934
|
+
// Answered in time: nothing to do.
|
|
935
|
+
if (!(await settleQuestion(store, job.runId, job.toolCallId, 'timeout')))
|
|
936
|
+
return false;
|
|
937
|
+
// Nobody answered: the agent goes on on its own assumptions.
|
|
938
|
+
const key = questionKey(job.conversation);
|
|
939
|
+
const waiting = await store.get(key);
|
|
940
|
+
const pending = waiting === null ? null : JSON.parse(waiting);
|
|
941
|
+
if (pending?.runId === job.runId && pending.toolCallId === job.toolCallId)
|
|
942
|
+
await store.delete(key);
|
|
943
|
+
await service
|
|
944
|
+
.skip({
|
|
945
|
+
runId: job.runId,
|
|
946
|
+
toolCallId: job.toolCallId,
|
|
947
|
+
answeredByRef: job.actor.id,
|
|
948
|
+
answeredVia: name,
|
|
949
|
+
})
|
|
950
|
+
.catch(() => { });
|
|
951
|
+
await executor().enqueue({
|
|
952
|
+
kind: 'resume',
|
|
953
|
+
channel: name,
|
|
954
|
+
conversation: job.conversation,
|
|
955
|
+
runId: job.streamRunId,
|
|
956
|
+
threadId: job.threadId,
|
|
957
|
+
actor: job.actor,
|
|
958
|
+
}, jobId(name, 'resume', job.runId, job.toolCallId));
|
|
959
|
+
return true;
|
|
960
|
+
}));
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
if (job.kind === 'resume') {
|
|
964
|
+
await step('read', () => retry(async () => {
|
|
965
|
+
const service = await serviceFor();
|
|
966
|
+
await readTurn(service, await storeFor(service), job.actor, job.runId, job.threadId, job.conversation, null);
|
|
967
|
+
return null;
|
|
968
|
+
}));
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
const received = job.message;
|
|
972
|
+
const prepared = await step('prepare', () => retry(() => prepare(received)));
|
|
973
|
+
if (prepared.stop)
|
|
974
|
+
return;
|
|
975
|
+
const turn = await step('act', () => retry(() => act(received, prepared.actor, prepared.threadId)));
|
|
976
|
+
if (turn === null)
|
|
977
|
+
return;
|
|
978
|
+
await step('read', () => retry(async () => {
|
|
979
|
+
const service = await serviceFor();
|
|
980
|
+
await readTurn(service, await storeFor(service), prepared.actor, turn.runId, turn.threadId, received.conversation, received);
|
|
981
|
+
return null;
|
|
982
|
+
}));
|
|
983
|
+
}
|
|
984
|
+
catch (error) {
|
|
985
|
+
if (error?.name === 'WorkflowSuspended')
|
|
986
|
+
throw error;
|
|
987
|
+
report(error, message);
|
|
988
|
+
}
|
|
989
|
+
};
|
|
990
|
+
let current;
|
|
991
|
+
/** Durable when an engine is there (given, or the agent's), else in this process. */
|
|
992
|
+
const executor = () => {
|
|
993
|
+
if (current)
|
|
994
|
+
return current;
|
|
995
|
+
const engine = options.durable === false
|
|
996
|
+
? undefined
|
|
997
|
+
: typeof options.durable === 'object'
|
|
998
|
+
? options.durable
|
|
999
|
+
: registeredAgentEngine();
|
|
1000
|
+
if (engine === undefined && options.durable === true)
|
|
1001
|
+
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`);
|
|
1002
|
+
if (engine !== undefined) {
|
|
1003
|
+
registerChannelWorkflows(engine, runnerFor);
|
|
1004
|
+
current = durableExecutor(engine);
|
|
1005
|
+
return current;
|
|
1006
|
+
}
|
|
1007
|
+
current = inlineExecutor((job) => run(job, (_name, fn) => fn()));
|
|
1008
|
+
return current;
|
|
1009
|
+
};
|
|
1010
|
+
registry.set(name, {
|
|
1011
|
+
adapter,
|
|
1012
|
+
run,
|
|
1013
|
+
relay: (proposal, conversation, service) => relay(proposal, conversation, service),
|
|
1014
|
+
});
|
|
1015
|
+
// Registered on the engine as soon as there is one: a restarted process resumes the jobs a crash
|
|
1016
|
+
// interrupted only for workflows it knows.
|
|
1017
|
+
if (typeof options.durable === 'object')
|
|
1018
|
+
registerChannelWorkflows(options.durable, runnerFor);
|
|
1019
|
+
else if (options.durable !== false)
|
|
1020
|
+
followAgentEngine();
|
|
1021
|
+
const notify = async (event) => {
|
|
1022
|
+
try {
|
|
1023
|
+
await options.onWebhook?.({ channel: name, ...event });
|
|
1024
|
+
}
|
|
1025
|
+
catch {
|
|
1026
|
+
// Observation never changes the answer.
|
|
1027
|
+
}
|
|
1028
|
+
};
|
|
1029
|
+
const handleRequest = async (request, logger) => {
|
|
1030
|
+
const empty = { accepted: 0, duplicates: 0, messages: [] };
|
|
618
1031
|
const challenge = adapter.challenge?.(request) ?? null;
|
|
619
1032
|
if (challenge !== null) {
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
1033
|
+
await notify({ status: 'challenge', ...empty });
|
|
1034
|
+
return {
|
|
1035
|
+
status: challenge.status,
|
|
1036
|
+
body: challenge.body,
|
|
1037
|
+
contentType: challenge.contentType ?? 'text/plain',
|
|
1038
|
+
};
|
|
625
1039
|
}
|
|
626
1040
|
if (request.method !== 'POST') {
|
|
627
|
-
|
|
628
|
-
return;
|
|
1041
|
+
await notify({ status: 'method_not_allowed', ...empty });
|
|
1042
|
+
return { status: 405, body: { error: 'method_not_allowed' } };
|
|
629
1043
|
}
|
|
630
1044
|
if (!(await adapter.verify(request))) {
|
|
631
|
-
|
|
632
|
-
return;
|
|
1045
|
+
await notify({ status: 'unauthorized', ...empty });
|
|
1046
|
+
return { status: 401, body: { error: 'unauthorized' } };
|
|
633
1047
|
}
|
|
634
1048
|
const parsed = adapter.parse(request.body);
|
|
635
1049
|
const messages = parsed === null ? [] : Array.isArray(parsed) ? parsed : [parsed];
|
|
636
|
-
if (messages.length === 0)
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
const
|
|
643
|
-
//
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
1050
|
+
if (messages.length === 0) {
|
|
1051
|
+
const ignored = adapter.ignored?.(request.body) ?? null;
|
|
1052
|
+
const event = ignored?.event ??
|
|
1053
|
+
(typeof request.body === 'object' && request.body !== null
|
|
1054
|
+
? request.body.event
|
|
1055
|
+
: undefined);
|
|
1056
|
+
const reason = ignored?.reason ?? 'no message';
|
|
1057
|
+
// Logged with the event and the reason — never the content — so a dropped message can be
|
|
1058
|
+
// diagnosed.
|
|
1059
|
+
if (logger) {
|
|
1060
|
+
const log = ignored?.unexpected ? logger.warn : logger.debug;
|
|
1061
|
+
log?.call(logger, { channel: name, ...(typeof event === 'string' ? { event } : {}), reason }, 'channels: webhook ignored');
|
|
647
1062
|
}
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
1063
|
+
await notify({
|
|
1064
|
+
status: 'ignored',
|
|
1065
|
+
reason,
|
|
1066
|
+
...(typeof event === 'string' ? { event } : {}),
|
|
1067
|
+
...empty,
|
|
1068
|
+
});
|
|
1069
|
+
return { status: 200, body: { ok: true } };
|
|
1070
|
+
}
|
|
1071
|
+
const fresh = [];
|
|
1072
|
+
try {
|
|
1073
|
+
const store = await storeFor(await serviceFor());
|
|
1074
|
+
// Taken before the 200: a store or an engine that is down answers 500, and the provider
|
|
1075
|
+
// retries.
|
|
1076
|
+
for (const message of messages) {
|
|
1077
|
+
const key = `${name}:${message.id}`;
|
|
1078
|
+
if (!(await store.claim(key, dedupeTtlMs)))
|
|
1079
|
+
continue;
|
|
1080
|
+
try {
|
|
1081
|
+
await executor().enqueue({ kind: 'message', channel: name, conversation: message.conversation, message }, jobId(name, 'message', message.id));
|
|
1082
|
+
}
|
|
1083
|
+
catch (error) {
|
|
1084
|
+
await Promise.resolve(store.delete(key)).catch(() => { });
|
|
1085
|
+
throw error;
|
|
1086
|
+
}
|
|
1087
|
+
fresh.push(message);
|
|
658
1088
|
}
|
|
659
1089
|
}
|
|
660
|
-
|
|
1090
|
+
catch (error) {
|
|
1091
|
+
await notify({
|
|
1092
|
+
status: 'failed',
|
|
1093
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
1094
|
+
accepted: fresh.length,
|
|
1095
|
+
duplicates: 0,
|
|
1096
|
+
messages: fresh,
|
|
1097
|
+
});
|
|
1098
|
+
return { status: 500, body: { error: 'unavailable' } };
|
|
1099
|
+
}
|
|
1100
|
+
await notify({
|
|
1101
|
+
status: fresh.length > 0 ? 'accepted' : 'duplicate',
|
|
1102
|
+
accepted: fresh.length,
|
|
1103
|
+
duplicates: messages.length - fresh.length,
|
|
1104
|
+
messages: fresh,
|
|
1105
|
+
});
|
|
1106
|
+
return { status: 200, body: { ok: true } };
|
|
1107
|
+
};
|
|
1108
|
+
const handler = async (ctx) => {
|
|
1109
|
+
const answer = await handleRequest(requestOf(ctx), ctx.logger);
|
|
1110
|
+
if (answer.contentType !== undefined)
|
|
1111
|
+
ctx.response
|
|
1112
|
+
.status(answer.status)
|
|
1113
|
+
.header('content-type', answer.contentType)
|
|
1114
|
+
.send(answer.body);
|
|
1115
|
+
else
|
|
1116
|
+
ctx.response.status(answer.status).send(answer.body);
|
|
661
1117
|
};
|
|
662
1118
|
return Object.assign(handler, {
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
await Promise.allSettled([...inFlight]);
|
|
666
|
-
},
|
|
1119
|
+
drain: () => executor().drain(),
|
|
1120
|
+
handleRequest: (request) => handleRequest(request),
|
|
667
1121
|
});
|
|
668
1122
|
}
|
|
1123
|
+
/**
|
|
1124
|
+
* Relay an executed proposal's outcome to the conversation it was proposed in — once: the handler's
|
|
1125
|
+
* own wait and this share a claim in the channel's store. `false` when it was not relayed (no
|
|
1126
|
+
* channel recorded, the channel is not registered in this process, not executed, already relayed).
|
|
1127
|
+
*/
|
|
1128
|
+
async function deliverOutcome(proposal, service) {
|
|
1129
|
+
const address = record(proposal.executionContext?.pageContext?.channel);
|
|
1130
|
+
const channelName = typeof address?.name === 'string' ? address.name : undefined;
|
|
1131
|
+
const conversation = typeof address?.conversation === 'string' ? address.conversation : undefined;
|
|
1132
|
+
const registration = channelName === undefined ? undefined : registry.get(channelName);
|
|
1133
|
+
if (!registration || conversation === undefined)
|
|
1134
|
+
return false;
|
|
1135
|
+
return registration.relay(proposal, conversation, service);
|
|
1136
|
+
}
|
|
669
1137
|
/**
|
|
670
1138
|
* `actionProposalWorker.onSettled` for text channels: relays an executed proposal's outcome to the
|
|
671
|
-
* conversation it was proposed in, through the adapter registered under the recorded channel name
|
|
672
|
-
* Proposals from other surfaces are ignored.
|
|
673
|
-
* relayed once.
|
|
1139
|
+
* conversation it was proposed in, through the adapter registered under the recorded channel name
|
|
1140
|
+
* (with its `formatOutcome`, `texts` and `canDeliver`). Proposals from other surfaces are ignored.
|
|
1141
|
+
* Safe next to the handler's own wait: an outcome is relayed once.
|
|
674
1142
|
*
|
|
675
1143
|
* ```ts
|
|
676
1144
|
* // config/agent.ts
|