@adonis-agora/agent 0.64.1 → 0.65.0

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