@adonis-agora/agent 0.62.0 → 0.64.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 (149) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/providers/agent_provider.d.ts.map +1 -1
  3. package/dist/providers/agent_provider.js +1 -12
  4. package/dist/providers/agent_provider.js.map +1 -1
  5. package/dist/src/action-proposal-service.d.ts +3 -2
  6. package/dist/src/action-proposal-service.d.ts.map +1 -1
  7. package/dist/src/action-proposal-service.js +1 -0
  8. package/dist/src/action-proposal-service.js.map +1 -1
  9. package/dist/src/action-proposal-text.d.ts +5 -0
  10. package/dist/src/action-proposal-text.d.ts.map +1 -1
  11. package/dist/src/action-proposal-text.js +1 -0
  12. package/dist/src/action-proposal-text.js.map +1 -1
  13. package/dist/src/actor-resolver.d.ts +12 -4
  14. package/dist/src/actor-resolver.d.ts.map +1 -1
  15. package/dist/src/actor-resolver.js +24 -7
  16. package/dist/src/actor-resolver.js.map +1 -1
  17. package/dist/src/ag-ui/adapter.d.ts.map +1 -1
  18. package/dist/src/ag-ui/adapter.js +32 -6
  19. package/dist/src/ag-ui/adapter.js.map +1 -1
  20. package/dist/src/ag-ui/core/encoder.d.ts +2 -1
  21. package/dist/src/ag-ui/core/encoder.d.ts.map +1 -1
  22. package/dist/src/ag-ui/core/encoder.js +16 -5
  23. package/dist/src/ag-ui/core/encoder.js.map +1 -1
  24. package/dist/src/ag-ui/core/index.d.ts +2 -2
  25. package/dist/src/ag-ui/core/index.d.ts.map +1 -1
  26. package/dist/src/ag-ui/core/index.js +2 -2
  27. package/dist/src/ag-ui/core/index.js.map +1 -1
  28. package/dist/src/ag-ui/core/input.d.ts +21 -3
  29. package/dist/src/ag-ui/core/input.d.ts.map +1 -1
  30. package/dist/src/ag-ui/core/input.js +24 -6
  31. package/dist/src/ag-ui/core/input.js.map +1 -1
  32. package/dist/src/ag-ui/core/stream.d.ts +24 -2
  33. package/dist/src/ag-ui/core/stream.d.ts.map +1 -1
  34. package/dist/src/ag-ui/core/stream.js +25 -3
  35. package/dist/src/ag-ui/core/stream.js.map +1 -1
  36. package/dist/src/ag-ui/core/types.d.ts +2 -2
  37. package/dist/src/ag-ui/core/types.d.ts.map +1 -1
  38. package/dist/src/ag-ui/core/types.js +2 -2
  39. package/dist/src/ag-ui/core/types.js.map +1 -1
  40. package/dist/src/ag-ui/frames.d.ts +5 -1
  41. package/dist/src/ag-ui/frames.d.ts.map +1 -1
  42. package/dist/src/ag-ui/frames.js +13 -3
  43. package/dist/src/ag-ui/frames.js.map +1 -1
  44. package/dist/src/ag-ui/index.d.ts +1 -1
  45. package/dist/src/ag-ui/index.d.ts.map +1 -1
  46. package/dist/src/ag-ui/index.js +1 -1
  47. package/dist/src/ag-ui/index.js.map +1 -1
  48. package/dist/src/agent-service.d.ts +36 -3
  49. package/dist/src/agent-service.d.ts.map +1 -1
  50. package/dist/src/agent-service.js +65 -1
  51. package/dist/src/agent-service.js.map +1 -1
  52. package/dist/src/attachment-limits.d.ts +10 -0
  53. package/dist/src/attachment-limits.d.ts.map +1 -0
  54. package/dist/src/attachment-limits.js +13 -0
  55. package/dist/src/attachment-limits.js.map +1 -0
  56. package/dist/src/channels/adapters/evolution_api.d.ts +58 -0
  57. package/dist/src/channels/adapters/evolution_api.d.ts.map +1 -0
  58. package/dist/src/channels/adapters/evolution_api.js +256 -0
  59. package/dist/src/channels/adapters/evolution_api.js.map +1 -0
  60. package/dist/src/channels/adapters/telegram.d.ts +34 -0
  61. package/dist/src/channels/adapters/telegram.d.ts.map +1 -0
  62. package/dist/src/channels/adapters/telegram.js +196 -0
  63. package/dist/src/channels/adapters/telegram.js.map +1 -0
  64. package/dist/src/channels/adapters/whatsapp_cloud.d.ts +31 -0
  65. package/dist/src/channels/adapters/whatsapp_cloud.d.ts.map +1 -0
  66. package/dist/src/channels/adapters/whatsapp_cloud.js +177 -0
  67. package/dist/src/channels/adapters/whatsapp_cloud.js.map +1 -0
  68. package/dist/src/channels/adapters/whatsmiau.d.ts +26 -0
  69. package/dist/src/channels/adapters/whatsmiau.d.ts.map +1 -0
  70. package/dist/src/channels/adapters/whatsmiau.js +18 -0
  71. package/dist/src/channels/adapters/whatsmiau.js.map +1 -0
  72. package/dist/src/channels/handler.d.ts +190 -0
  73. package/dist/src/channels/handler.d.ts.map +1 -0
  74. package/dist/src/channels/handler.js +626 -0
  75. package/dist/src/channels/handler.js.map +1 -0
  76. package/dist/src/channels/http.d.ts +45 -0
  77. package/dist/src/channels/http.d.ts.map +1 -0
  78. package/dist/src/channels/http.js +147 -0
  79. package/dist/src/channels/http.js.map +1 -0
  80. package/dist/src/channels/index.d.ts +17 -0
  81. package/dist/src/channels/index.d.ts.map +1 -0
  82. package/dist/src/channels/index.js +16 -0
  83. package/dist/src/channels/index.js.map +1 -0
  84. package/dist/src/channels/markdown.d.ts +12 -0
  85. package/dist/src/channels/markdown.d.ts.map +1 -0
  86. package/dist/src/channels/markdown.js +120 -0
  87. package/dist/src/channels/markdown.js.map +1 -0
  88. package/dist/src/channels/questions.d.ts +45 -0
  89. package/dist/src/channels/questions.d.ts.map +1 -0
  90. package/dist/src/channels/questions.js +131 -0
  91. package/dist/src/channels/questions.js.map +1 -0
  92. package/dist/src/channels/split.d.ts +8 -0
  93. package/dist/src/channels/split.d.ts.map +1 -0
  94. package/dist/src/channels/split.js +45 -0
  95. package/dist/src/channels/split.js.map +1 -0
  96. package/dist/src/channels/store.d.ts +78 -0
  97. package/dist/src/channels/store.d.ts.map +1 -0
  98. package/dist/src/channels/store.js +151 -0
  99. package/dist/src/channels/store.js.map +1 -0
  100. package/dist/src/channels/types.d.ts +145 -0
  101. package/dist/src/channels/types.d.ts.map +1 -0
  102. package/dist/src/channels/types.js +7 -0
  103. package/dist/src/channels/types.js.map +1 -0
  104. package/dist/src/genui/builtins.d.ts.map +1 -1
  105. package/dist/src/genui/builtins.js +65 -6
  106. package/dist/src/genui/builtins.js.map +1 -1
  107. package/dist/src/genui/catalog.d.ts +11 -2
  108. package/dist/src/genui/catalog.d.ts.map +1 -1
  109. package/dist/src/genui/catalog.js +8 -0
  110. package/dist/src/genui/catalog.js.map +1 -1
  111. package/dist/src/genui/text.d.ts +4 -3
  112. package/dist/src/genui/text.d.ts.map +1 -1
  113. package/dist/src/genui/text.js +25 -24
  114. package/dist/src/genui/text.js.map +1 -1
  115. package/dist/src/genui/tools.d.ts +2 -1
  116. package/dist/src/genui/tools.d.ts.map +1 -1
  117. package/dist/src/genui/tools.js +6 -7
  118. package/dist/src/genui/tools.js.map +1 -1
  119. package/dist/src/index.d.ts +3 -2
  120. package/dist/src/index.d.ts.map +1 -1
  121. package/dist/src/index.js +3 -2
  122. package/dist/src/index.js.map +1 -1
  123. package/dist/src/opencode/turns.d.ts +4 -2
  124. package/dist/src/opencode/turns.d.ts.map +1 -1
  125. package/dist/src/opencode/turns.js.map +1 -1
  126. package/dist/src/react/core/ag-ui-backend.d.ts +38 -14
  127. package/dist/src/react/core/ag-ui-backend.d.ts.map +1 -1
  128. package/dist/src/react/core/ag-ui-backend.js +182 -17
  129. package/dist/src/react/core/ag-ui-backend.js.map +1 -1
  130. package/dist/src/react/core/agent-chat-transport.d.ts.map +1 -1
  131. package/dist/src/react/core/agent-chat-transport.js +22 -1
  132. package/dist/src/react/core/agent-chat-transport.js.map +1 -1
  133. package/dist/src/react/core/backend.d.ts +11 -0
  134. package/dist/src/react/core/backend.d.ts.map +1 -1
  135. package/dist/src/react/core/backend.js.map +1 -1
  136. package/dist/src/react/core/index.d.ts +1 -1
  137. package/dist/src/react/core/index.d.ts.map +1 -1
  138. package/dist/src/react/core/index.js.map +1 -1
  139. package/dist/src/stores/lucid-schema.d.ts +24 -6
  140. package/dist/src/stores/lucid-schema.d.ts.map +1 -1
  141. package/dist/src/stores/lucid-schema.js +47 -6
  142. package/dist/src/stores/lucid-schema.js.map +1 -1
  143. package/dist/src/stores/lucid.d.ts +2 -0
  144. package/dist/src/stores/lucid.d.ts.map +1 -1
  145. package/dist/src/stores/lucid.js +4 -0
  146. package/dist/src/stores/lucid.js.map +1 -1
  147. package/package.json +6 -1
  148. package/skills/agent-setup/SKILL.md +40 -3
  149. package/skills/agent-tools/SKILL.md +3 -0
@@ -0,0 +1,626 @@
1
+ import { DEFAULT_TEXT_ACTION_PROPOSAL_VOCABULARY, ptBrActionProposalText, } from '../action-proposal-text.js';
2
+ import { AgentService, AttachmentRefusedError } from '../agent-service.js';
3
+ import { ChannelMediaTooLargeError } from './http.js';
4
+ import { toChannelMarkdown } from './markdown.js';
5
+ import { DEFAULT_CHANNEL_QUESTION_TEXTS, formatChannelQuestion, parseChannelAnswer, ptBrChannelQuestionTexts, } from './questions.js';
6
+ import { splitMessage } from './split.js';
7
+ 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
+ }
72
+ const BUTTON_ID = /^agora:(approve|reject):([^\s]+)$/;
73
+ /** The tail of a proposal id a button carries — Telegram's `callback_data` holds 64 bytes. */
74
+ const BUTTON_REF_LENGTH = 32;
75
+ const OUTCOME_POLL_MS = 500;
76
+ /** How long "this outcome was relayed" is remembered. */
77
+ const OUTCOME_TTL_MS = 7 * 24 * 60 * 60 * 1000;
78
+ const buttonRef = (proposalId) => proposalId.slice(-BUTTON_REF_LENGTH);
79
+ /** The button ids for a proposal: what {@link ChannelRouteHandler} maps back to its decision. */
80
+ export function proposalButtonIds(proposalId) {
81
+ const ref = buttonRef(proposalId);
82
+ return { approve: `agora:approve:${ref}`, reject: `agora:reject:${ref}` };
83
+ }
84
+ function requestOf(ctx) {
85
+ return {
86
+ method: ctx.request.method().toUpperCase(),
87
+ url: ctx.request.url(true),
88
+ header: (name) => ctx.request.header(name),
89
+ params: (ctx.params ?? {}),
90
+ body: ctx.request.body(),
91
+ rawBody: ctx.request.raw(),
92
+ };
93
+ }
94
+ /** `AgentService` from the app's container, for code that runs outside a request. */
95
+ async function containerService() {
96
+ const { default: app } = await import('@adonisjs/core/services/app');
97
+ return (await app.container.make(AgentService));
98
+ }
99
+ const registry = new Map();
100
+ const mergeTexts = (overrides = {}, base = DEFAULT_CHANNEL_TEXTS) => ({
101
+ ...base,
102
+ ...overrides,
103
+ questions: { ...base.questions, ...overrides.questions },
104
+ });
105
+ /** Send `text` converted to the channel's markdown, split at its length limit. */
106
+ async function deliverText(adapter, conversation, text) {
107
+ const { markdown, maxLength } = adapter.capabilities;
108
+ for (const piece of splitMessage(toChannelMarkdown(text, markdown), maxLength))
109
+ await adapter.send(conversation, { text: piece });
110
+ }
111
+ /** What an executed proposal tells the person; `null` when it did not execute. */
112
+ function outcomeText(proposal, texts) {
113
+ const status = proposal.execution?.status;
114
+ if (status === 'failed')
115
+ return texts.actionFailed;
116
+ if (status !== 'succeeded')
117
+ return null;
118
+ // What its presentation said (`present` / `emitUi` text), else a plain confirmation.
119
+ return proposal.outcome?.text?.trim() || texts.actionSucceeded;
120
+ }
121
+ /**
122
+ * Relay an executed proposal's outcome to the conversation it was proposed in — once: the handler's
123
+ * own wait and this share a claim in the channel's store. `false` when it was not relayed (no
124
+ * channel recorded, the channel is not registered in this process, not executed, already relayed).
125
+ */
126
+ async function deliverOutcome(proposal, service) {
127
+ const address = record(proposal.executionContext?.pageContext?.channel);
128
+ const name = typeof address?.name === 'string' ? address.name : undefined;
129
+ const conversation = typeof address?.conversation === 'string' ? address.conversation : undefined;
130
+ const registration = name === undefined ? undefined : registry.get(name);
131
+ if (!registration || conversation === undefined)
132
+ return false;
133
+ return relayOnce(await registration.store(service), registration.adapter, await registration.texts(service), conversation, proposal);
134
+ }
135
+ /** Send an executed proposal's outcome unless it was sent already (by this or another replica). */
136
+ async function relayOnce(store, adapter, texts, conversation, proposal) {
137
+ const text = outcomeText(proposal, texts);
138
+ if (text === null)
139
+ return false;
140
+ if (!(await store.claim(`${adapter.name}:outcome:${proposal.id}`, OUTCOME_TTL_MS)))
141
+ return false;
142
+ await deliverText(adapter, conversation, text);
143
+ return true;
144
+ }
145
+ function record(value) {
146
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
147
+ ? value
148
+ : undefined;
149
+ }
150
+ /** `image/jpeg` → `jpeg`, for a file that arrives without a name. */
151
+ const extensionOf = (contentType) => (contentType.split('/')[1] ?? 'bin').split(/[;+]/)[0] ?? 'bin';
152
+ /**
153
+ * The webhook route for one channel. Each request is verified (`401` when it is not the provider's),
154
+ * parsed, deduplicated by the provider's message id, and acknowledged with `200` at once; the turn
155
+ * runs after the response, so a slow model never makes the provider retry. Then:
156
+ *
157
+ * - media are downloaded and attached (held to the attachment store's limits), or refused with a text;
158
+ * - the message is sent with text-only capabilities (components arrive as their `fallbackText`);
159
+ * a text decision ("yes", "confirm #ID") is answered with its reply instead of starting a turn;
160
+ * - a press on a proposal's button decides that proposal (`via` = the adapter's name);
161
+ * - a question the turn asks is sent as text, one at a time, and the next messages answer it;
162
+ * - the reply is converted to the channel's markdown, split at its length limit, and sent;
163
+ * - a proposal the turn left pending is sent with Confirm/Cancel buttons — or, on a channel without
164
+ * them, with a text instruction in the configured `actionProposalText` vocabulary;
165
+ * - an approved proposal's outcome is relayed once it executed (see `outcomeTimeoutMs`, and
166
+ * `channels.onSettled` for one that runs later).
167
+ *
168
+ * Wants `actionApprovalMode: 'independent'`: a blocking approval holds the turn open until someone
169
+ * decides it in the app, so the channel sends what it has and `texts.blockingApproval`.
170
+ *
171
+ * Registers the adapter under its `name` for `channels.onSettled` — names must be unique.
172
+ */
173
+ export function handleChannel(adapter, options) {
174
+ let resolvedTexts;
175
+ /** The texts, over the defaults in the language of the service's vocabulary — chosen once. */
176
+ const textsFor = (service) => {
177
+ if (resolvedTexts)
178
+ return resolvedTexts;
179
+ const texts = mergeTexts(options.texts, channelTextsFor(service?.actionProposalVocabulary?.() ?? null));
180
+ if (service)
181
+ resolvedTexts = texts;
182
+ return texts;
183
+ };
184
+ const dedupeTtlMs = options.dedupeTtlMs ?? 24 * 60 * 60 * 1000;
185
+ const timeoutMs = options.timeoutMs ?? 5 * 60 * 1000;
186
+ const outcomeTimeoutMs = options.outcomeTimeoutMs ?? 60_000;
187
+ const questionTimeoutMs = options.questionTimeoutMs ?? 30 * 60 * 1000;
188
+ const { capabilities } = adapter;
189
+ const buttons = (capabilities.buttons ?? 0) >= 2;
190
+ const inFlight = new Set();
191
+ let resolvedStore;
192
+ /** The configured store, else Lucid over the agent store's database, else memory — chosen once. */
193
+ const storeFor = (service) => {
194
+ resolvedStore ??= (async () => {
195
+ if (options.store)
196
+ return options.store;
197
+ const agent = options.service ?? service ?? (await containerService().catch(() => undefined));
198
+ const db = agent?.lucidDatabase?.() ?? null;
199
+ return db === null ? new InMemoryChannelStore() : lucidChannelStore(db);
200
+ })();
201
+ return resolvedStore;
202
+ };
203
+ registry.set(adapter.name, {
204
+ adapter,
205
+ texts: async (service) => textsFor(options.service ?? service ?? (await containerService().catch(() => undefined))),
206
+ store: storeFor,
207
+ });
208
+ const deliver = (conversation, text) => deliverText(adapter, conversation, text);
209
+ const questionKey = (conversation) => `${adapter.name}:question:${conversation}`;
210
+ const commandsFor = (service, proposalId, withId) => {
211
+ const vocabulary = service.actionProposalVocabulary?.() ?? DEFAULT_TEXT_ACTION_PROPOSAL_VOCABULARY;
212
+ const suffix = withId ? ` #${proposalId}` : '';
213
+ return {
214
+ approve: `${vocabulary.approve[0] ?? 'yes'}${suffix}`,
215
+ reject: `${vocabulary.reject[0] ?? 'no'}${suffix}`,
216
+ };
217
+ };
218
+ const sendProposal = async (service, conversation, proposal, withId) => {
219
+ const texts = textsFor(service);
220
+ const summary = texts.proposal(proposal);
221
+ const instruction = texts.instruction(commandsFor(service, proposal.id, withId));
222
+ const asText = toChannelMarkdown(`${summary}\n\n${instruction}`, capabilities.markdown);
223
+ if (!buttons) {
224
+ for (const piece of splitMessage(asText, capabilities.maxLength))
225
+ await adapter.send(conversation, { text: piece });
226
+ return;
227
+ }
228
+ const ids = proposalButtonIds(proposal.id);
229
+ const message = {
230
+ text: toChannelMarkdown(summary, capabilities.markdown).slice(0, capabilities.maxLength),
231
+ buttons: [
232
+ { id: ids.approve, label: texts.approve },
233
+ { id: ids.reject, label: texts.reject },
234
+ ],
235
+ fallbackText: asText.slice(0, capabilities.maxLength),
236
+ instruction: toChannelMarkdown(instruction, capabilities.markdown),
237
+ };
238
+ await adapter.send(conversation, message);
239
+ };
240
+ /** Wait for an approved proposal to run, then relay what it said (or that it failed). */
241
+ const relayOutcome = async (service, actor, threadId, proposalId, conversation) => {
242
+ if (outcomeTimeoutMs <= 0 || !service.listActionProposals)
243
+ return;
244
+ const deadline = Date.now() + outcomeTimeoutMs;
245
+ while (Date.now() < deadline) {
246
+ const current = (await service.listActionProposals(actor, threadId)).find((proposal) => proposal.id === proposalId);
247
+ if (current?.decision !== 'approved')
248
+ return;
249
+ const texts = textsFor(service);
250
+ if (outcomeText(current, texts) !== null) {
251
+ await relayOnce(await storeFor(service), adapter, texts, conversation, current);
252
+ return;
253
+ }
254
+ await new Promise((resolve) => setTimeout(resolve, OUTCOME_POLL_MS));
255
+ }
256
+ };
257
+ /** A press on one of our proposal buttons: decide it. `false` → not one of ours. */
258
+ const pressButton = async (service, actor, threadId, message) => {
259
+ const match = message.buttonId === undefined ? null : BUTTON_ID.exec(message.buttonId);
260
+ if (!match || !service.decideActionProposal || !service.listActionProposals)
261
+ return false;
262
+ const decision = match[1] === 'approve' ? 'approved' : 'rejected';
263
+ const ref = match[2] ?? '';
264
+ const proposal = threadId === null
265
+ ? undefined
266
+ : (await service.listActionProposals(actor, threadId)).find((candidate) => buttonRef(candidate.id) === ref);
267
+ if (threadId === null || !proposal) {
268
+ await deliver(message.conversation, service.actionProposalReply({ status: 'not_found' }, decision));
269
+ return true;
270
+ }
271
+ const result = await service.decideActionProposal(actor, threadId, proposal.id, decision, {}, adapter.name);
272
+ await deliver(message.conversation, service.actionProposalReply(result, decision));
273
+ if (result.status === 'applied' && decision === 'approved')
274
+ await relayOutcome(service, actor, threadId, proposal.id, message.conversation);
275
+ return true;
276
+ };
277
+ const askNext = (service, conversation, pending) => {
278
+ const question = pending.questions[pending.index];
279
+ if (!question)
280
+ return Promise.resolve();
281
+ return deliver(conversation, formatChannelQuestion(question, {
282
+ index: pending.index,
283
+ total: pending.questions.length,
284
+ ...(pending.preamble !== undefined ? { preamble: pending.preamble } : {}),
285
+ }, textsFor(service).questions));
286
+ };
287
+ /** The person's message answers the question in front of them; the last one resumes the run. */
288
+ const answerQuestion = async (service, store, actor, message, pending) => {
289
+ const question = pending.questions[pending.index];
290
+ if (!question || !service.answer)
291
+ return;
292
+ const texts = textsFor(service);
293
+ const parsed = parseChannelAnswer(question, message.text, texts.questions.skipWord);
294
+ if (parsed.status === 'invalid') {
295
+ await deliver(message.conversation, texts.questions.invalid(parsed.problem));
296
+ await askNext(service, message.conversation, pending);
297
+ return;
298
+ }
299
+ const next = {
300
+ ...pending,
301
+ index: pending.index + 1,
302
+ answers: parsed.status === 'answer'
303
+ ? { ...pending.answers, [question.id]: parsed.values }
304
+ : pending.answers,
305
+ };
306
+ const key = questionKey(message.conversation);
307
+ if (next.index < next.questions.length) {
308
+ await store.set(key, JSON.stringify(next), questionTimeoutMs);
309
+ await askNext(service, message.conversation, next);
310
+ return;
311
+ }
312
+ await store.delete(key);
313
+ // The turn that asked is still being read: what the agent says next reaches the person there.
314
+ await service.answer({
315
+ runId: next.runId,
316
+ toolCallId: next.toolCallId,
317
+ answers: next.answers,
318
+ answeredByRef: actor.id,
319
+ answeredVia: adapter.name,
320
+ });
321
+ };
322
+ /** Download a message's files and stage them for the turn; refuse what cannot be attached. */
323
+ const attachMedia = async (service, actor, message) => {
324
+ const refs = [];
325
+ const limits = service.attachmentLimits?.() ?? null;
326
+ const texts = textsFor(service);
327
+ for (const media of message.media ?? []) {
328
+ const refuse = (reason) => deliver(message.conversation, texts.mediaRefused(reason, media, limits));
329
+ if (limits === null || !adapter.download || !service.stageAttachment) {
330
+ await refuse('disabled');
331
+ continue;
332
+ }
333
+ const declared = media.contentType?.split(';')[0]?.trim().toLowerCase();
334
+ if (declared !== undefined && !limits.allowedContentTypes.includes(declared)) {
335
+ await refuse('type');
336
+ continue;
337
+ }
338
+ if (media.sizeBytes !== undefined && media.sizeBytes > limits.maxBytes) {
339
+ await refuse('size');
340
+ continue;
341
+ }
342
+ try {
343
+ const file = await adapter.download(media, { maxBytes: limits.maxBytes });
344
+ const attachment = await service.stageAttachment(actor, {
345
+ data: file.data,
346
+ contentType: file.contentType,
347
+ filename: file.filename ?? `${media.kind}.${extensionOf(file.contentType)}`,
348
+ });
349
+ refs.push({ mediaId: attachment.mediaId });
350
+ }
351
+ catch (error) {
352
+ if (error instanceof ChannelMediaTooLargeError)
353
+ await refuse('size');
354
+ else if (error instanceof AttachmentRefusedError)
355
+ await refuse(error.status === 413 ? 'size' : error.status === 415 ? 'type' : 'failed');
356
+ else {
357
+ await refuse('failed');
358
+ options.onError?.(error, message);
359
+ }
360
+ }
361
+ }
362
+ return refs;
363
+ };
364
+ /** Read a run's frames until it ends (or parks on something a text channel cannot settle). */
365
+ const readTurn = async (service, store, actor, runId, threadId, conversation) => {
366
+ const parts = [];
367
+ const proposals = new Map();
368
+ let failed = false;
369
+ let blocked = false;
370
+ let deadlineAt = Date.now() + timeoutMs;
371
+ /** The question set this reader is waiting on, while it waits. */
372
+ let asking = null;
373
+ const flush = async () => {
374
+ const text = parts.join('').trim();
375
+ parts.length = 0;
376
+ if (text !== '')
377
+ await deliver(conversation, text);
378
+ };
379
+ const stream = service.subscribe(runId)[Symbol.asyncIterator]();
380
+ let reading;
381
+ try {
382
+ for (;;) {
383
+ let timer;
384
+ const timeout = new Promise((resolve) => {
385
+ timer = setTimeout(() => resolve('timeout'), Math.max(0, deadlineAt - Date.now()));
386
+ });
387
+ // A read that lost a race to the timer is still the next frame: keep it, never ask twice.
388
+ reading ??= stream.next();
389
+ const next = await Promise.race([reading, timeout]).finally(() => clearTimeout(timer));
390
+ if (next !== 'timeout')
391
+ reading = undefined;
392
+ if (next === 'timeout') {
393
+ if (asking !== null) {
394
+ // Nobody answered: the agent goes on on its own assumptions.
395
+ const { runId: parkedRun, toolCallId } = asking;
396
+ asking = null;
397
+ await store.delete(questionKey(conversation));
398
+ await service
399
+ .skip({
400
+ runId: parkedRun,
401
+ toolCallId,
402
+ answeredByRef: actor.id,
403
+ answeredVia: adapter.name,
404
+ })
405
+ .catch(() => { });
406
+ deadlineAt = Date.now() + timeoutMs;
407
+ continue;
408
+ }
409
+ failed = parts.length === 0;
410
+ await service.cancel(runId).catch(() => { });
411
+ break;
412
+ }
413
+ if (next.done)
414
+ break;
415
+ const frame = next.value;
416
+ if (asking !== null) {
417
+ // The run moved on: the questions were answered (or skipped elsewhere).
418
+ asking = null;
419
+ deadlineAt = Date.now() + timeoutMs;
420
+ }
421
+ if (frame.t === 'text')
422
+ parts.push(frame.v);
423
+ else if (frame.t === 'component' && frame.fallbackText) {
424
+ // Only a component the turn negotiated as drawable comes as a frame — never with
425
+ // text-only capabilities; its text is all a channel can show.
426
+ parts.push(`\n\n${frame.fallbackText}\n\n`);
427
+ }
428
+ else if (frame.t === 'approval') {
429
+ if (frame.target?.kind === 'proposal') {
430
+ proposals.set(frame.target.proposalId, {
431
+ id: frame.target.proposalId,
432
+ toolName: frame.toolName,
433
+ ...(frame.confirmation ? { confirmation: frame.confirmation } : {}),
434
+ });
435
+ }
436
+ else {
437
+ // Blocking mode: the run waits for a decision this channel cannot make.
438
+ blocked = true;
439
+ break;
440
+ }
441
+ }
442
+ else if (frame.t === 'elicitation') {
443
+ const questions = frame.request.questions;
444
+ if (!service.answer || questions.length === 0) {
445
+ await service
446
+ .skip({
447
+ runId: frame.runId,
448
+ toolCallId: frame.id,
449
+ answeredByRef: actor.id,
450
+ answeredVia: adapter.name,
451
+ })
452
+ .catch(() => { });
453
+ continue;
454
+ }
455
+ await flush();
456
+ const pending = {
457
+ threadId,
458
+ runId: frame.runId,
459
+ toolCallId: frame.id,
460
+ ...(frame.request.preamble !== undefined ? { preamble: frame.request.preamble } : {}),
461
+ questions,
462
+ index: 0,
463
+ answers: {},
464
+ };
465
+ await store.set(questionKey(conversation), JSON.stringify(pending), questionTimeoutMs);
466
+ await askNext(service, conversation, pending);
467
+ asking = { runId: frame.runId, toolCallId: frame.id };
468
+ deadlineAt = Date.now() + questionTimeoutMs;
469
+ }
470
+ else if (frame.t === 'error')
471
+ failed = true;
472
+ }
473
+ }
474
+ finally {
475
+ void stream.return?.();
476
+ }
477
+ return { text: parts.join('').trim(), proposals: [...proposals.values()], failed, blocked };
478
+ };
479
+ const handleMessage = async (service, message) => {
480
+ await adapter.acknowledge?.(message).catch(() => { });
481
+ const texts = textsFor(service);
482
+ const store = await storeFor(service);
483
+ const actor = await options.actor(message);
484
+ if (actor === null) {
485
+ if (texts.unknownSender !== undefined)
486
+ await deliver(message.conversation, texts.unknownSender);
487
+ return;
488
+ }
489
+ const threadId = (await options.thread(actor, message)) ?? null;
490
+ const ours = message.buttonId !== undefined && BUTTON_ID.test(message.buttonId);
491
+ if (!ours) {
492
+ const waiting = await store.get(questionKey(message.conversation));
493
+ const pending = waiting === null ? null : JSON.parse(waiting);
494
+ if (pending !== null && (pending.threadId === null || pending.threadId === threadId)) {
495
+ await answerQuestion(service, store, actor, message, pending);
496
+ return;
497
+ }
498
+ }
499
+ if (await pressButton(service, actor, threadId, message))
500
+ return;
501
+ const attachments = await attachMedia(service, actor, message);
502
+ // A file nobody could attach, and no caption: there is nothing left to answer.
503
+ if (message.text === '' && attachments.length === 0)
504
+ return;
505
+ const pageContext = typeof options.pageContext === 'function'
506
+ ? options.pageContext(message)
507
+ : (options.pageContext ?? { kind: adapter.name });
508
+ const channel = { name: adapter.name, conversation: message.conversation };
509
+ const sent = await service.send({
510
+ actor,
511
+ message: message.text,
512
+ ...(threadId !== null ? { threadId } : {}),
513
+ ...(options.agentName !== undefined ? { agentName: options.agentName } : {}),
514
+ ...(attachments.length > 0 ? { attachments } : {}),
515
+ // Nothing is drawn: every component arrives as its text.
516
+ uiCapabilities: { components: [] },
517
+ pageContext: { ...pageContext, channel },
518
+ hostContext: {
519
+ channel: adapter.name,
520
+ conversation: message.conversation,
521
+ messageId: message.id,
522
+ },
523
+ });
524
+ if (threadId === null)
525
+ await options.onThreadCreated?.(sent.threadId, actor, message);
526
+ if ('proposalDecision' in sent) {
527
+ await deliver(message.conversation, sent.text);
528
+ const decided = sent.proposalDecision;
529
+ if ('proposal' in decided &&
530
+ decided.status === 'applied' &&
531
+ decided.proposal?.decision === 'approved')
532
+ await relayOutcome(service, actor, sent.threadId, decided.proposal.id, message.conversation);
533
+ return;
534
+ }
535
+ // A queued message starts later under its own id: its answer is read the same way.
536
+ const runId = sent.queued === true ? (sent.runId ?? sent.messageId) : sent.runId;
537
+ const turn = await readTurn(service, store, actor, runId, sent.threadId, message.conversation);
538
+ if (turn.text !== '')
539
+ await deliver(message.conversation, turn.text);
540
+ if (turn.failed)
541
+ await deliver(message.conversation, texts.failed);
542
+ if (turn.blocked)
543
+ await deliver(message.conversation, texts.blockingApproval);
544
+ for (const proposal of turn.proposals)
545
+ await sendProposal(service, message.conversation, proposal, turn.proposals.length > 1);
546
+ };
547
+ const track = (work) => {
548
+ inFlight.add(work);
549
+ void work.finally(() => inFlight.delete(work));
550
+ };
551
+ const handler = async (ctx) => {
552
+ const request = requestOf(ctx);
553
+ const challenge = adapter.challenge?.(request) ?? null;
554
+ if (challenge !== null) {
555
+ ctx.response
556
+ .status(challenge.status)
557
+ .header('content-type', challenge.contentType ?? 'text/plain')
558
+ .send(challenge.body);
559
+ return;
560
+ }
561
+ if (request.method !== 'POST') {
562
+ ctx.response.status(405).send({ error: 'method_not_allowed' });
563
+ return;
564
+ }
565
+ if (!(await adapter.verify(request))) {
566
+ ctx.response.status(401).send({ error: 'unauthorized' });
567
+ return;
568
+ }
569
+ const parsed = adapter.parse(request.body);
570
+ const messages = parsed === null ? [] : Array.isArray(parsed) ? parsed : [parsed];
571
+ if (messages.length > 0) {
572
+ const service = options.service ??
573
+ (await ctx.containerResolver.make(AgentService));
574
+ const store = await storeFor(service);
575
+ const fresh = [];
576
+ // Claimed before the 200: a store that is down answers 500, and the provider retries.
577
+ for (const message of messages) {
578
+ if (await store.claim(`${adapter.name}:${message.id}`, dedupeTtlMs))
579
+ fresh.push(message);
580
+ }
581
+ for (const message of fresh) {
582
+ track(handleMessage(service, message).catch((error) => {
583
+ if (options.onError)
584
+ options.onError(error, message);
585
+ else
586
+ console.error('[@adonis-agora/agent] Channel message failed', {
587
+ channel: adapter.name,
588
+ message: error instanceof Error ? error.message : String(error),
589
+ });
590
+ }));
591
+ }
592
+ }
593
+ ctx.response.status(200).send({ ok: true });
594
+ };
595
+ return Object.assign(handler, {
596
+ async drain() {
597
+ while (inFlight.size > 0)
598
+ await Promise.allSettled([...inFlight]);
599
+ },
600
+ });
601
+ }
602
+ /**
603
+ * `actionProposalWorker.onSettled` for text channels: relays an executed proposal's outcome to the
604
+ * conversation it was proposed in, through the adapter registered under the recorded channel name.
605
+ * Proposals from other surfaces are ignored. Safe next to the handler's own wait: an outcome is
606
+ * relayed once.
607
+ *
608
+ * ```ts
609
+ * // config/agent.ts
610
+ * actionProposalWorker: { onSettled: channels.onSettled }
611
+ * ```
612
+ */
613
+ export async function relayChannelOutcome(proposal) {
614
+ await deliverOutcome(proposal);
615
+ }
616
+ export const channels = {
617
+ /** See {@link handleChannel}. */
618
+ handle: handleChannel,
619
+ /** See {@link relayChannelOutcome}. */
620
+ onSettled: relayChannelOutcome,
621
+ /** Relay one proposal's outcome now; `true` when it was sent. */
622
+ deliverOutcome: (proposal, service) => deliverOutcome(proposal, service),
623
+ /** The adapter registered under `name` (by `channels.handle`), if any. */
624
+ adapter: (name) => registry.get(name)?.adapter,
625
+ };
626
+ //# sourceMappingURL=handler.js.map