@noodleseed/one 0.170.0 → 0.171.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 (105) hide show
  1. package/dist/cli.d.ts.map +1 -1
  2. package/dist/cli.js +3 -0
  3. package/dist/cli.js.map +1 -1
  4. package/dist/commands/catalog.d.ts.map +1 -1
  5. package/dist/commands/catalog.js +2 -1
  6. package/dist/commands/catalog.js.map +1 -1
  7. package/dist/commands/whatsapp-ops.d.ts +13 -0
  8. package/dist/commands/whatsapp-ops.d.ts.map +1 -0
  9. package/dist/commands/whatsapp-ops.js +244 -0
  10. package/dist/commands/whatsapp-ops.js.map +1 -0
  11. package/dist/commands/whatsapp-output.d.ts +3 -0
  12. package/dist/commands/whatsapp-output.d.ts.map +1 -0
  13. package/dist/commands/whatsapp-output.js +70 -0
  14. package/dist/commands/whatsapp-output.js.map +1 -0
  15. package/dist/dev.js +2 -0
  16. package/dist/dev.js.map +1 -1
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +1 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/plugin-mode/plugin-operation-contract.d.ts +1 -1
  22. package/node_modules/@noodle-borg/agent-kit/dist/curated/command-groups.js +4 -0
  23. package/node_modules/@noodle-borg/agent-kit/dist/curated/error-fixes.js +1 -0
  24. package/node_modules/@noodle-borg/agent-kit/dist/generated/example-files.js +1 -1
  25. package/node_modules/@noodle-borg/agent-kit/dist/generated/surface.js +3 -0
  26. package/node_modules/@noodle-borg/agent-kit/dist/skill-embedded-assistant-ref.js +6 -0
  27. package/node_modules/@noodle-borg/agent-kit/package.json +1 -1
  28. package/node_modules/@noodle-borg/app-audit/dist/audit-embedded-assistant.js +2 -0
  29. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-configuration.js +2 -1
  30. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-model-context.js +9 -4
  31. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-turn-context.d.ts +16 -0
  32. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-turn-context.js +19 -0
  33. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-coordinator.d.ts +145 -0
  34. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-coordinator.js +378 -0
  35. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-inbox.js +160 -0
  36. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-provider-blocks.d.ts +18 -0
  37. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-provider-blocks.js +69 -0
  38. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-spend.js +82 -0
  39. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-store.d.ts +42 -0
  40. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-store.js +76 -0
  41. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-types.d.ts +134 -0
  42. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-types.js +72 -0
  43. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-work.d.ts +20 -0
  44. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-work.js +221 -0
  45. package/node_modules/@noodle-borg/assistant-gateway/dist/messaging-tool-dispatch.d.ts +15 -0
  46. package/node_modules/@noodle-borg/assistant-gateway/dist/messaging-tool-dispatch.js +34 -0
  47. package/node_modules/@noodle-borg/assistant-gateway/dist/model-request.d.ts +19 -1
  48. package/node_modules/@noodle-borg/assistant-gateway/dist/model-request.js +22 -6
  49. package/node_modules/@noodle-borg/assistant-gateway/dist/portable.d.ts +7 -0
  50. package/node_modules/@noodle-borg/assistant-gateway/dist/portable.js +7 -0
  51. package/node_modules/@noodle-borg/assistant-gateway/dist/postgres-assistant-appearance-store.js +83 -0
  52. package/node_modules/@noodle-borg/assistant-gateway/dist/postgres-assistant-elevation.js +151 -0
  53. package/node_modules/@noodle-borg/assistant-gateway/dist/postgres-assistant-interaction-writes.js +45 -0
  54. package/node_modules/@noodle-borg/assistant-gateway/dist/postgres-assistant-interactions.js +340 -0
  55. package/node_modules/@noodle-borg/assistant-gateway/dist/postgres-assistant.js +439 -0
  56. package/node_modules/@noodle-borg/assistant-gateway/dist/postgres-channel-store.js +102 -0
  57. package/node_modules/@noodle-borg/assistant-gateway/dist/postgres-continuity-store.js +159 -0
  58. package/node_modules/@noodle-borg/assistant-gateway/dist/postgres-elevation-store.js +110 -0
  59. package/node_modules/@noodle-borg/assistant-gateway/dist/postgres-embed-store.js +134 -0
  60. package/node_modules/@noodle-borg/assistant-gateway/dist/postgres.js +11 -0
  61. package/node_modules/@noodle-borg/assistant-gateway/dist/public-surface.d.ts +8 -0
  62. package/node_modules/@noodle-borg/assistant-gateway/dist/public-surface.js +23 -0
  63. package/node_modules/@noodle-borg/authoring/dist/assistant.d.ts +17 -2
  64. package/node_modules/@noodle-borg/authoring/dist/assistant.js +48 -19
  65. package/node_modules/@noodle-borg/authoring/dist/index.d.ts +1 -1
  66. package/node_modules/@noodle-borg/authoring/dist/index.js +1 -1
  67. package/node_modules/@noodle-borg/authoring/dist/load-authored.js +1 -1
  68. package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-channels.js +121 -0
  69. package/node_modules/@noodle-borg/cli-catalog/dist/index.js +1 -0
  70. package/node_modules/@noodle-borg/compiler/dist/artifact/config.js +1 -1
  71. package/node_modules/@noodle-borg/compiler/dist/errors.d.ts +1 -1
  72. package/node_modules/@noodle-borg/compiler/dist/manifest/schema.d.ts +72 -12
  73. package/node_modules/@noodle-borg/compiler/dist/manifest/schema.js +51 -27
  74. package/node_modules/@noodle-borg/compiler/dist/manifest/website-projection.d.ts +1 -0
  75. package/node_modules/@noodle-borg/compiler/dist/manifest/website-projection.js +19 -0
  76. package/node_modules/@noodle-borg/module-audit/dist/index.js +37 -0
  77. package/node_modules/@noodle-borg/module-audit/dist/postgres-audit.js +126 -0
  78. package/node_modules/@noodle-borg/module-audit/dist/route.js +66 -0
  79. package/node_modules/@noodle-borg/runtime/dist/managed-origins.js +8 -4
  80. package/node_modules/@noodle-borg/runtime/dist/postgres-state-handles.js +311 -0
  81. package/node_modules/@noodle-borg/runtime/dist/postgres.js +4 -0
  82. package/node_modules/@noodle-borg/service/dist/assistant-composition.js +18 -0
  83. package/node_modules/@noodle-borg/service/dist/build-info.js +5 -2
  84. package/node_modules/@noodle-borg/service/dist/business-information/source-ingestion-coordinator.js +7 -0
  85. package/node_modules/@noodle-borg/service/dist/channels/360dialog.js +233 -0
  86. package/node_modules/@noodle-borg/service/dist/channels/cipher.js +37 -0
  87. package/node_modules/@noodle-borg/service/dist/channels/composition.js +37 -0
  88. package/node_modules/@noodle-borg/service/dist/channels/inference-guard.js +53 -0
  89. package/node_modules/@noodle-borg/service/dist/channels/runtime.js +337 -0
  90. package/node_modules/@noodle-borg/service/dist/channels/worker-loop.js +50 -0
  91. package/node_modules/@noodle-borg/service/dist/routes/assistant-agent.js +62 -34
  92. package/node_modules/@noodle-borg/service/dist/routes/assistant-model-binding.js +6 -2
  93. package/node_modules/@noodle-borg/service/dist/routes/business-information-channels.js +14 -1
  94. package/node_modules/@noodle-borg/service/dist/routes/whatsapp.js +238 -0
  95. package/node_modules/@noodle-borg/service/dist/service.js +30 -32
  96. package/node_modules/@noodle-borg/service/package.json +1 -1
  97. package/node_modules/@noodle-borg/wire-contracts/dist/application-channels.d.ts +33 -0
  98. package/node_modules/@noodle-borg/wire-contracts/dist/application-channels.js +9 -0
  99. package/node_modules/@noodle-borg/wire-contracts/dist/index.d.ts +1 -0
  100. package/node_modules/@noodle-borg/wire-contracts/dist/index.js +1 -0
  101. package/node_modules/@noodle-borg/wire-contracts/dist/service-info.d.ts +2 -0
  102. package/node_modules/@noodle-borg/wire-contracts/dist/service-info.js +10 -2
  103. package/node_modules/@noodle-borg/wire-contracts/dist/whatsapp.d.ts +426 -0
  104. package/node_modules/@noodle-borg/wire-contracts/dist/whatsapp.js +213 -0
  105. package/package.json +2 -2
@@ -0,0 +1,337 @@
1
+ import { beginChannelProviderBlock, CHANNEL_RETENTION_MS, ChannelCoordinator, ChannelError, channelDigest, channelProviderBlocks, finishChannelProviderBlock, messagingSurfaceOf, projectArtifactForSurface, resolveInvocationContextSnapshot, withAssistantTurnExecutionAuthority, } from '@noodle-borg/assistant-gateway/portable';
2
+ import { runAgentTurn } from '../routes/assistant-agent.js';
3
+ import { resolveAssistantKnowledge } from '../routes/assistant-knowledge.js';
4
+ import { resolveAssistantModelBinding } from '../routes/assistant-model-binding.js';
5
+ import { activeAssistantTarget } from '../routes/assistant-session-target.js';
6
+ import { resolveConfigScope } from '../store.js';
7
+ import { Dialog360, verifyWhatsAppWebhookSecret, WHATSAPP_CALLBACK_HEADER } from './360dialog.js';
8
+ import { verifiedInferenceBound, withChannelInferenceGuard } from './inference-guard.js';
9
+ export class WhatsAppRuntime {
10
+ options;
11
+ deps;
12
+ channels;
13
+ cursor;
14
+ constructor(options, deps) {
15
+ this.options = options;
16
+ this.deps = deps;
17
+ this.channels = new ChannelCoordinator(options.store, () => deps.clock?.().getTime() ?? Date.now());
18
+ options.worker.attach(() => this.sweep());
19
+ }
20
+ async provider(binding) {
21
+ const values = await this.deps.registry.configStore.resolveConfigValues('secret', resolveConfigScope(binding.tenant));
22
+ const apiKey = values[binding.apiKeySecret], webhookSecret = values[binding.webhookSecret];
23
+ if (!apiKey ||
24
+ !webhookSecret ||
25
+ apiKey === webhookSecret ||
26
+ !verifyWhatsAppWebhookSecret(webhookSecret, webhookSecret))
27
+ throw new ChannelError('channel_credentials_missing');
28
+ await this.channels.checkCredentials(binding.id, channelDigest(JSON.stringify([apiKey, webhookSecret])));
29
+ return { adapter: new Dialog360(apiKey, this.options.providerFetch), webhookSecret };
30
+ }
31
+ webhookUrl(req, binding) {
32
+ return `${this.deps.serviceBase(req).replace(/\/$/, '')}/v1/channels/whatsapp/webhooks/${binding.id}`;
33
+ }
34
+ async target(binding) {
35
+ const target = await activeAssistantTarget(this.deps, binding.tenant);
36
+ if (!target || target.deploymentId !== binding.deploymentId)
37
+ throw new ChannelError('deployment_changed');
38
+ const surface = messagingSurfaceOf(target.served.artifact.server.assistant);
39
+ if (!surface)
40
+ throw new ChannelError('messaging_surface_missing');
41
+ if (binding.capabilities.some((cap) => !surface.capabilities.some((authored) => authored.kind === cap.kind && authored.name === cap.name)))
42
+ throw new ChannelError('capability_not_authored');
43
+ const artifact = projectArtifactForSurface(target.served.artifact, binding.capabilities);
44
+ if (artifact.tools.some((tool) => tool.annotations?.readOnlyHint !== true || tool._meta?.ui))
45
+ throw new ChannelError('messaging_action_unsupported');
46
+ // This first slice has no separately budgeted connector-backed inference. Pure reads and
47
+ // the existing corpus search remain available; external lookups require their owning slice.
48
+ const external = (fulfilment) => fulfilment.kind === 'operation' || fulfilment.steps.some((step) => step.kind === 'operation');
49
+ if (artifact.tools.some((tool) => external(tool.fulfilment)) ||
50
+ (artifact.server.context?.ambient && external(artifact.server.context.ambient.fulfilment)))
51
+ throw new ChannelError('external_lookup_not_enabled');
52
+ return { ...target, served: { ...target.served, artifact } };
53
+ }
54
+ async doctor(id, req) {
55
+ const binding = await this.channels.internal(id);
56
+ const checks = [];
57
+ const check = async (name, work) => {
58
+ try {
59
+ await work();
60
+ checks.push({ name, status: 'ready' });
61
+ }
62
+ catch (error) {
63
+ checks.push({
64
+ name,
65
+ status: 'unavailable',
66
+ code: error instanceof ChannelError ? error.code : 'check_unavailable',
67
+ });
68
+ }
69
+ };
70
+ await check('durability', async () => {
71
+ if (!this.options.store.durable)
72
+ throw new ChannelError('durable_storage_required');
73
+ });
74
+ await check('worker', async () => {
75
+ if (!this.options.worker.ready())
76
+ throw new ChannelError('worker_unavailable');
77
+ });
78
+ await check('deployment', async () => {
79
+ await this.target(binding);
80
+ });
81
+ await check('model_cost', async () => {
82
+ const model = await resolveAssistantModelBinding(await this.target(binding), binding.tenant, binding.deploymentId, this.deps, 'whatsapp');
83
+ if (!model)
84
+ throw new ChannelError('model_not_configured');
85
+ verifiedInferenceBound(model, this.channels.now());
86
+ });
87
+ await check('history_policy', async () => {
88
+ await this.retention(binding);
89
+ });
90
+ await check('knowledge', async () => {
91
+ const target = await this.target(binding);
92
+ if ((target.served.artifact.server.knowledge?.length ?? 0) > 0 &&
93
+ !(await resolveAssistantKnowledge(target.served)))
94
+ throw new ChannelError('knowledge_unavailable');
95
+ });
96
+ await check('provider_asset', async () => {
97
+ const health = await (await this.provider(binding)).adapter.health();
98
+ if (health.phoneNumberId !== binding.phoneNumberId)
99
+ throw new ChannelError('asset_mismatch');
100
+ if (!health.canSend)
101
+ throw new ChannelError('provider_messaging_unavailable');
102
+ });
103
+ await check('webhook', async () => {
104
+ const state = await this.webhook(id, req);
105
+ if (!state.matches || !state.authenticated)
106
+ throw new ChannelError('webhook_not_configured');
107
+ });
108
+ const ready = checks.every((check) => check.status === 'ready');
109
+ if (ready)
110
+ await this.channels.markReady(id, binding.revision);
111
+ return { ready, revision: binding.revision, checks, webhookUrl: this.webhookUrl(req, binding) };
112
+ }
113
+ async webhook(id, req) {
114
+ const binding = await this.channels.internal(id);
115
+ const { adapter, webhookSecret } = await this.provider(binding);
116
+ const current = await adapter.webhook();
117
+ return {
118
+ url: this.webhookUrl(req, binding),
119
+ matches: current.url === this.webhookUrl(req, binding),
120
+ authenticated: verifyWhatsAppWebhookSecret(current.headers?.[WHATSAPP_CALLBACK_HEADER], webhookSecret),
121
+ };
122
+ }
123
+ async configureWebhook(id, req) {
124
+ const binding = await this.channels.internal(id);
125
+ if (binding.state !== 'paused')
126
+ throw new ChannelError('pause_before_webhook_change');
127
+ const { adapter, webhookSecret } = await this.provider(binding);
128
+ const existing = await adapter.webhook();
129
+ const url = this.webhookUrl(req, binding);
130
+ if (existing.url && existing.url !== url)
131
+ throw new ChannelError('webhook_ownership_conflict');
132
+ if (existing.url !== url ||
133
+ !verifyWhatsAppWebhookSecret(existing.headers?.[WHATSAPP_CALLBACK_HEADER], webhookSecret))
134
+ await adapter.configureWebhook(url, webhookSecret);
135
+ }
136
+ async projection(tenant) {
137
+ const binding = await this.channels.get(tenant);
138
+ if (!binding)
139
+ return {
140
+ status: 'unavailable',
141
+ capabilities: [],
142
+ reason: 'WhatsApp is not configured.',
143
+ };
144
+ const view = {
145
+ bindingId: binding.id,
146
+ revision: binding.revision,
147
+ capabilities: binding.capabilities.map((cap) => `${cap.kind}:${cap.name}`),
148
+ };
149
+ if (binding.state !== 'enabled')
150
+ return { ...view, status: 'paused' };
151
+ try {
152
+ await this.target(await this.channels.internal(binding.id));
153
+ return { ...view, status: 'ready' };
154
+ }
155
+ catch {
156
+ return {
157
+ ...view,
158
+ status: 'unavailable',
159
+ reason: 'The active deployment or installation is unavailable.',
160
+ };
161
+ }
162
+ }
163
+ async providerBlock(id, participantId, blocked, actor, key, protectedPhone) {
164
+ const operation = await beginChannelProviderBlock(this.channels.store, id, participantId, blocked ? 'blocked' : 'unblocked', actor, key, this.channels.now(), protectedPhone);
165
+ if (!operation)
166
+ return;
167
+ try {
168
+ const binding = await this.channels.internal(id);
169
+ if (binding.generation !== operation.generation)
170
+ throw new ChannelError('authority_changed');
171
+ const { adapter } = await this.provider(binding);
172
+ const result = await adapter.setBlocked(operation.address.value, blocked);
173
+ await finishChannelProviderBlock(this.channels.store, id, participantId, operation.revision, result.state, this.channels.now(), result.code);
174
+ }
175
+ catch {
176
+ await finishChannelProviderBlock(this.channels.store, id, participantId, operation.revision, 'unknown', this.channels.now(), 'provider_outcome_unknown');
177
+ }
178
+ }
179
+ async blocks(id, after) {
180
+ const local = await this.channels.blocks(id, after);
181
+ const provider = await channelProviderBlocks(this.channels.store, id, this.channels.now(), after);
182
+ const ids = [
183
+ ...new Set([
184
+ ...local.map((item) => item.participantId),
185
+ ...provider.map((item) => item.participantId),
186
+ ]),
187
+ ];
188
+ const data = ids
189
+ .sort()
190
+ .slice(0, 100)
191
+ .map((participantId) => {
192
+ const block = local.find((item) => item.participantId === participantId);
193
+ const match = provider.find((item) => item.participantId === participantId);
194
+ return {
195
+ ...(block ?? {
196
+ participantId,
197
+ actor: match.actor,
198
+ reason: 'operator',
199
+ until: null,
200
+ }),
201
+ local: !!block && (block.until === null || block.until > this.channels.now()),
202
+ ...(match
203
+ ? {
204
+ provider: {
205
+ desired: match.desired,
206
+ state: match.state,
207
+ updatedAt: match.updatedAt,
208
+ ...(match.code ? { code: match.code } : {}),
209
+ },
210
+ }
211
+ : {}),
212
+ };
213
+ });
214
+ return { data, ...(data.length === 100 ? { next: data.at(-1).participantId } : {}) };
215
+ }
216
+ async retention(binding) {
217
+ const days = (await this.options.historyDays?.(binding.tenant)) ?? 7;
218
+ if (!Number.isInteger(days) || days < 1)
219
+ throw new ChannelError('history_policy_unavailable');
220
+ return Math.min(CHANNEL_RETENTION_MS, days * 86_400_000);
221
+ }
222
+ async sweep() {
223
+ const page = await this.channels.bindingPage(this.cursor);
224
+ this.cursor = page.after;
225
+ // Bounded parallelism and rotating registry pages prevent a busy tenant starving later ones.
226
+ for (let offset = 0; offset < page.ids.length; offset += 5) {
227
+ await Promise.all(page.ids.slice(offset, offset + 5).map((id) => this.process(id)));
228
+ }
229
+ await this.options.store.prune(this.channels.now(), 1000);
230
+ }
231
+ async process(id) {
232
+ try {
233
+ if (this.options.worker.signal.aborted)
234
+ return;
235
+ const binding = await this.channels.internal(id);
236
+ const retention = await this.retention(binding);
237
+ await this.channels.expireHistory(id, retention);
238
+ const work = await this.channels.claim(id, retention);
239
+ if (work)
240
+ await this.answer(work);
241
+ for (let n = 0; n < 5 && !this.options.worker.signal.aborted; n++) {
242
+ const send = await this.channels.prepareSend(id);
243
+ if (!send)
244
+ break;
245
+ await this.target(await this.channels.internal(id));
246
+ const { adapter } = await this.provider(send.binding);
247
+ await this.channels.assertSend(id, send.event.id, send.event.lease);
248
+ this.options.worker.signal.throwIfAborted();
249
+ await this.channels.sent(id, send.event.id, send.event.lease, await adapter.send({ to: send.participant.address, text: send.event.reply }));
250
+ }
251
+ }
252
+ catch {
253
+ this.deps.logger?.warn('assistant.channel.worker', { code: 'channel_work_failed' });
254
+ }
255
+ }
256
+ async answer(work) {
257
+ const { binding, participant, event } = work;
258
+ const abort = new AbortController();
259
+ const signal = AbortSignal.any([
260
+ abort.signal,
261
+ this.options.worker.signal,
262
+ AbortSignal.timeout(60_000),
263
+ ]);
264
+ const historyRetention = await this.retention(binding);
265
+ const beforeStep = async () => {
266
+ if ((await this.retention(binding)) !== historyRetention)
267
+ throw new ChannelError('history_policy_changed');
268
+ signal.throwIfAborted();
269
+ await this.provider(await this.channels.internal(binding.id));
270
+ await this.channels.renew(binding.id, event.id, event.lease);
271
+ await this.target(await this.channels.internal(binding.id));
272
+ };
273
+ const timer = setInterval(() => {
274
+ void beforeStep().catch(() => abort.abort());
275
+ }, 10_000);
276
+ timer.unref?.();
277
+ try {
278
+ const target = await this.target(binding);
279
+ const model = await resolveAssistantModelBinding(target, binding.tenant, binding.deploymentId, this.deps, 'whatsapp');
280
+ if (!model)
281
+ throw new ChannelError('model_not_configured');
282
+ const session = {
283
+ kind: 'messaging',
284
+ channel: 'whatsapp',
285
+ id: event.id,
286
+ bindingId: binding.id,
287
+ participantId: participant.id,
288
+ tenant: binding.tenant,
289
+ deploymentId: binding.deploymentId,
290
+ caller: { identityKind: 'anonymous', subject: participant.id, roles: [], scopes: [] },
291
+ history: participant.history.map(({ role, content }) => ({ role, content })),
292
+ modelToolUses: participant.modelToolUses ?? [],
293
+ };
294
+ const context = await resolveInvocationContextSnapshot({
295
+ artifact: target.served.artifact,
296
+ executeDeps: withAssistantTurnExecutionAuthority({ ...target.served.deps, signal }, target.served.artifact, session),
297
+ caller: session.caller,
298
+ instant: new Date(this.channels.now()),
299
+ });
300
+ let text = '', failure;
301
+ await runAgentTurn(target, session, event.text, context, this.deps, (result) => {
302
+ if (result.event === 'content' && typeof result.data.delta === 'string')
303
+ text += result.data.delta;
304
+ if (result.event === 'error')
305
+ failure = String(result.data.code ?? 'answer_failed');
306
+ }, undefined, undefined, undefined, false, {
307
+ binding: withChannelInferenceGuard(model, this.channels, binding.id, event.id, beforeStep),
308
+ beforeStep,
309
+ claimTool: (name) => this.channels.claimTool(binding.id, participant.id, name),
310
+ signal,
311
+ });
312
+ if (failure || !text.trim())
313
+ throw new ChannelError(failure ?? 'answer_unavailable');
314
+ const disclosure = participant.history.length === 0
315
+ ? `I’m ${target.served.artifact.server.branding?.name ?? target.served.artifact.server.title}’s AI assistant.\n\n`
316
+ : '';
317
+ const answer = disclosure + text.trim();
318
+ const bounded = answer.length <= 3500
319
+ ? answer
320
+ : `${answer.slice(0, 3350).replace(/[\uD800-\uDBFF]$/u, '')}\n\nAsk me to expand on any part.`;
321
+ await this.channels.complete(binding.id, event.id, event.lease, bounded, historyRetention);
322
+ }
323
+ catch (error) {
324
+ const code = error instanceof ChannelError ? error.code : 'answer_failed';
325
+ try {
326
+ await this.channels.complete(binding.id, event.id, event.lease, `I couldn’t complete that request. For help, contact ${binding.supportEmail}.`, historyRetention, code);
327
+ }
328
+ catch {
329
+ await this.channels.fail(binding.id, event.id, event.lease, code);
330
+ }
331
+ }
332
+ finally {
333
+ clearInterval(timer);
334
+ }
335
+ }
336
+ }
337
+ //# sourceMappingURL=runtime.js.map
@@ -0,0 +1,50 @@
1
+ /** A process scheduler, never the owner of acknowledged work. PostgreSQL leases own recovery. */
2
+ export class ChannelWorkerLoop {
3
+ onError;
4
+ run;
5
+ timer;
6
+ running;
7
+ stopped = true;
8
+ tickAt = 0;
9
+ abort = new AbortController();
10
+ get signal() {
11
+ return this.abort.signal;
12
+ }
13
+ constructor(onError = () => undefined) {
14
+ this.onError = onError;
15
+ }
16
+ attach(run) {
17
+ this.run = run;
18
+ }
19
+ start() {
20
+ if (this.timer)
21
+ return;
22
+ this.stopped = false;
23
+ this.abort = new AbortController();
24
+ this.timer = setInterval(() => this.wake(), 1000);
25
+ this.timer.unref?.();
26
+ this.wake();
27
+ }
28
+ ready() {
29
+ return !this.stopped && !!this.run && Date.now() - this.tickAt < 90_000;
30
+ }
31
+ wake() {
32
+ if (this.stopped || this.running || !this.run)
33
+ return;
34
+ this.tickAt = Date.now();
35
+ this.running = this.run()
36
+ .catch(() => this.onError('channel_worker_failed'))
37
+ .finally(() => {
38
+ this.running = undefined;
39
+ });
40
+ }
41
+ async stop() {
42
+ this.stopped = true;
43
+ this.abort.abort();
44
+ if (this.timer)
45
+ clearInterval(this.timer);
46
+ this.timer = undefined;
47
+ await this.running;
48
+ }
49
+ }
50
+ //# sourceMappingURL=worker-loop.js.map
@@ -1,6 +1,6 @@
1
1
  import { ADMISSION_DEFAULTS } from '@noodle-borg/admission-limits/portable';
2
2
  import { assistantCoreModelMessages, assistantTurnModelContextMessages, requestAssistantSuggestedPrompts, requestModelCompletion, resolveAssistantContextProviderModelResult, } from '@noodle-borg/assistant-gateway/model-runtime';
3
- import { assistantGuideModelContext, assistantModelToolOncePerSession, assistantModelToolRequiredWhenVisible, assistantOmittedToolResult, assistantViewAvailableData, dispatchAssistantTool, projectAssistantGuide, publicSurfaceOf, recoverableAssistantView, remainingTurnTokens, selectAssistantModelTools, toolTouchesDelegatedAuth, withAssistantSessionExecutionAuthority, } from '@noodle-borg/assistant-gateway/portable';
3
+ import { assistantGuideModelContext, assistantModelToolOncePerSession, assistantModelToolRequiredWhenVisible, assistantOmittedToolResult, assistantViewAvailableData, dispatchAssistantTool, dispatchMessagingReadTool, isMessagingTurn, projectAssistantGuide, publicSurfaceOf, recoverableAssistantView, remainingTurnTokens, selectAssistantModelTools, toolTouchesDelegatedAuth, withAssistantSessionExecutionAuthority, } from '@noodle-borg/assistant-gateway/portable';
4
4
  import { validateJsonSchemaWithDefaults } from '@noodle-borg/compiler';
5
5
  import { guardedFetch } from '@noodle-borg/connector-http';
6
6
  import { CapabilityBudget } from '@noodle-borg/managed-capabilities';
@@ -34,14 +34,15 @@ export function createAssistantTurnStats() {
34
34
  };
35
35
  }
36
36
  /** Run one model/tool loop against the immutable invocation-context snapshot for this turn. */
37
- export async function runAgentTurn(target, session, message, context, deps, emit, modelContext, pageContext, stats = createAssistantTurnStats(), suggestions = false) {
37
+ export async function runAgentTurn(target, session, message, context, deps, emit, modelContext, pageContext, stats = createAssistantTurnStats(), suggestions = false, transport) {
38
38
  // Every terminal failure in this loop is one shape: a code, and the turn ends. Naming it keeps the
39
39
  // dozen sites readable and stops a new one inventing a different envelope.
40
40
  const fail = (code) => emit({ event: 'error', data: { code } });
41
41
  const assistant = target.served.artifact.server.assistant;
42
42
  if (!assistant)
43
43
  return fail('assistant_unavailable');
44
- const binding = await resolveAssistantModelBinding(target, session.tenant, session.deploymentId, deps);
44
+ const binding = transport?.binding ??
45
+ (await resolveAssistantModelBinding(target, session.tenant, session.deploymentId, deps));
45
46
  if (!binding) {
46
47
  return emit({
47
48
  event: 'error',
@@ -106,6 +107,8 @@ export async function runAgentTurn(target, session, message, context, deps, emit
106
107
  ? undefined
107
108
  : AbortSignal.timeout(binding.requestPolicy.maxTurnMs);
108
109
  for (let step = 0; step < bounds.steps; step += 1) {
110
+ await transport?.beforeStep();
111
+ transport?.signal.throwIfAborted();
109
112
  if (remainingTokens !== undefined && remainingTokens <= 0)
110
113
  return fail('model_token_budget_exhausted');
111
114
  const requestTokenLimit = Math.min(binding.requestPolicy?.maxCompletionTokens ?? Number.MAX_SAFE_INTEGER, remainingTokens ?? Number.MAX_SAFE_INTEGER);
@@ -118,7 +121,11 @@ export async function runAgentTurn(target, session, message, context, deps, emit
118
121
  streamedContent += delta;
119
122
  emit({ event: 'content', data: { delta } });
120
123
  }
121
- : () => undefined, requiredToolForStep === undefined ? assistantKnowledgeModelTools(knowledge) : [], stepModelTools, requestTokenLimit === Number.MAX_SAFE_INTEGER ? undefined : requestTokenLimit, turnSignal, requiredToolForStep === undefined ? undefined : 'required');
124
+ : () => undefined, requiredToolForStep === undefined ? assistantKnowledgeModelTools(knowledge) : [], stepModelTools, requestTokenLimit === Number.MAX_SAFE_INTEGER ? undefined : requestTokenLimit, transport
125
+ ? turnSignal
126
+ ? AbortSignal.any([transport.signal, turnSignal])
127
+ : transport.signal
128
+ : turnSignal, requiredToolForStep === undefined ? undefined : 'required');
122
129
  stats.promptTokens += completion.usage?.promptTokens ?? 0;
123
130
  stats.completionTokens += completion.usage?.completionTokens ?? 0;
124
131
  stats.reasoningTokens += completion.usage?.reasoningTokens ?? 0;
@@ -137,7 +144,10 @@ export async function runAgentTurn(target, session, message, context, deps, emit
137
144
  const assistantContent = response.content || streamedContent;
138
145
  if (assistantContent)
139
146
  messages.push({ role: 'assistant', content: assistantContent });
140
- if (suggestions && step + 1 < bounds.steps && (remainingTokens ?? 1) > 0) {
147
+ if (!isMessagingTurn(session) &&
148
+ suggestions &&
149
+ step + 1 < bounds.steps &&
150
+ (remainingTokens ?? 1) > 0) {
141
151
  try {
142
152
  const prompts = await requestAssistantSuggestedPrompts(binding, messages, assistantModelFetcher(deps.modelFetch), stats, remainingTokens, turnSignal);
143
153
  if (prompts.length > 0) {
@@ -164,6 +174,7 @@ export async function runAgentTurn(target, session, message, context, deps, emit
164
174
  stats.toolCalls += 1;
165
175
  if (toolCallsThisTurn > bounds.toolCalls)
166
176
  return fail('tool_call_budget_exhausted');
177
+ await transport?.beforeStep();
167
178
  const knowledgeComponent = findAssistantKnowledgeComponent(knowledge, call.function.name);
168
179
  if (knowledge !== undefined && knowledgeComponent !== undefined) {
169
180
  let knowledgeArgs;
@@ -205,21 +216,23 @@ export async function runAgentTurn(target, session, message, context, deps, emit
205
216
  // The hosted broker exposes its delegated binding keys so the intercept can classify on
206
217
  // connector auth kind too; a broker without the method leaves the base classification alone.
207
218
  const delegatedKeys = target.served.deps.broker.assistantDelegatedAuthKeys?.();
208
- const elevation = await interceptForElevation({
209
- tool,
210
- session,
211
- assistant,
212
- ...(target.served.artifact.server.state === undefined
213
- ? {}
214
- : { state: target.served.artifact.server.state }),
215
- elevations: deps.elevations,
216
- ...(delegatedKeys !== undefined && delegatedKeys.size > 0
217
- ? {
218
- requiresDelegatedIdentity: (candidate) => toolTouchesDelegatedAuth(candidate, delegatedKeys),
219
- }
220
- : {}),
221
- now: deps.clock?.() ?? new Date(),
222
- });
219
+ const elevation = isMessagingTurn(session)
220
+ ? undefined
221
+ : await interceptForElevation({
222
+ tool,
223
+ session,
224
+ assistant,
225
+ ...(target.served.artifact.server.state === undefined
226
+ ? {}
227
+ : { state: target.served.artifact.server.state }),
228
+ elevations: deps.elevations,
229
+ ...(delegatedKeys !== undefined && delegatedKeys.size > 0
230
+ ? {
231
+ requiresDelegatedIdentity: (candidate) => toolTouchesDelegatedAuth(candidate, delegatedKeys),
232
+ }
233
+ : {}),
234
+ now: deps.clock?.() ?? new Date(),
235
+ });
223
236
  if (elevation) {
224
237
  stats.interactionCount += 1;
225
238
  emit(elevation.event);
@@ -245,19 +258,34 @@ export async function runAgentTurn(target, session, message, context, deps, emit
245
258
  if (coerced.issues.length > 0)
246
259
  return fail('invalid_tool_arguments');
247
260
  args = coerced.value;
248
- const dispatch = await dispatchAssistantTool({
249
- artifact: target.served.artifact,
250
- tool,
251
- arguments: args,
252
- executeDeps: withAssistantSessionExecutionAuthority({ ...target.served.deps, capabilityBudget }, target.served.artifact, session),
253
- caller: session.caller,
254
- context,
255
- session,
256
- store: deps.store,
257
- audit: deps.audit,
258
- now: () => deps.clock?.() ?? new Date(),
259
- onToolStarted: () => emit({ event: 'tool_started', data: { id: call.id, tool: tool.name } }),
260
- });
261
+ await transport?.beforeStep();
262
+ const dispatch = isMessagingTurn(session)
263
+ ? await dispatchMessagingReadTool({
264
+ artifact: target.served.artifact,
265
+ tool,
266
+ arguments: args,
267
+ executeDeps: {
268
+ ...target.served.deps,
269
+ capabilityBudget,
270
+ ...(transport?.signal ? { signal: transport.signal } : {}),
271
+ },
272
+ context,
273
+ session,
274
+ claimTool: transport?.claimTool ?? (async () => false),
275
+ })
276
+ : await dispatchAssistantTool({
277
+ artifact: target.served.artifact,
278
+ tool,
279
+ arguments: args,
280
+ executeDeps: withAssistantSessionExecutionAuthority({ ...target.served.deps, capabilityBudget }, target.served.artifact, session),
281
+ caller: session.caller,
282
+ context,
283
+ session,
284
+ store: deps.store,
285
+ audit: deps.audit,
286
+ now: () => deps.clock?.() ?? new Date(),
287
+ onToolStarted: () => emit({ event: 'tool_started', data: { id: call.id, tool: tool.name } }),
288
+ });
261
289
  if (assistantModelToolOncePerSession(tool) &&
262
290
  (dispatch.kind !== 'event' || dispatch.event !== 'error')) {
263
291
  modelTools = modelTools.filter((candidate) => candidate.name !== tool.name);
@@ -274,7 +302,7 @@ export async function runAgentTurn(target, session, message, context, deps, emit
274
302
  result: dispatch.output,
275
303
  arguments: args,
276
304
  }, (failure) => deps.logger?.warn('assistant.view.unresolved', { ...failure }));
277
- if (view) {
305
+ if (view && !isMessagingTurn(session)) {
278
306
  if (!dispatch.ephemeral)
279
307
  await deps.store.replaceLatestView(session.id, recoverableAssistantView(view));
280
308
  emit({ event: 'view_available', data: { ...view } });
@@ -1,11 +1,15 @@
1
1
  import { resolveManagedVariablesInString } from '../managed-config-expressions.js';
2
2
  import { resolveConfigScope } from '../store.js';
3
- export async function resolveAssistantModelBinding(target, tenant, deploymentId, deps) {
3
+ export async function resolveAssistantModelBinding(target, tenant, deploymentId, deps, channel) {
4
4
  const declaration = target.served.artifact.server.assistant?.model;
5
5
  if (declaration === undefined)
6
6
  return undefined;
7
7
  if (declaration.kind === 'noodle-managed') {
8
- return deps.managedModelResolver?.resolve({ tenant, deploymentId });
8
+ return deps.managedModelResolver?.resolve({
9
+ tenant,
10
+ deploymentId,
11
+ ...(channel ? { channel } : {}),
12
+ });
9
13
  }
10
14
  const scope = resolveConfigScope(tenant);
11
15
  const [variables, secrets] = await Promise.all([
@@ -59,7 +59,20 @@ export async function projectBusinessChannels(installation, canEdit, base, deps)
59
59
  };
60
60
  const registered = await deps.registry.getActiveByTenant(tenant);
61
61
  const target = registered && (await resolveTargetOrigins(registered));
62
- const shared = { revision: installation.revision, active: installation.intakeActive, canEdit };
62
+ const whatsapp = await deps.readWhatsApp?.(tenant);
63
+ const shared = {
64
+ revision: installation.revision,
65
+ active: installation.intakeActive,
66
+ canEdit,
67
+ ...(whatsapp
68
+ ? {
69
+ whatsapp: {
70
+ ...whatsapp,
71
+ ...(!installation.intakeActive ? { status: 'paused' } : {}),
72
+ },
73
+ }
74
+ : {}),
75
+ };
63
76
  if (!target?.deploymentId)
64
77
  return {
65
78
  ...shared,