@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.
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +3 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/catalog.d.ts.map +1 -1
- package/dist/commands/catalog.js +2 -1
- package/dist/commands/catalog.js.map +1 -1
- package/dist/commands/whatsapp-ops.d.ts +13 -0
- package/dist/commands/whatsapp-ops.d.ts.map +1 -0
- package/dist/commands/whatsapp-ops.js +244 -0
- package/dist/commands/whatsapp-ops.js.map +1 -0
- package/dist/commands/whatsapp-output.d.ts +3 -0
- package/dist/commands/whatsapp-output.d.ts.map +1 -0
- package/dist/commands/whatsapp-output.js +70 -0
- package/dist/commands/whatsapp-output.js.map +1 -0
- package/dist/dev.js +2 -0
- package/dist/dev.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/plugin-mode/plugin-operation-contract.d.ts +1 -1
- package/node_modules/@noodle-borg/agent-kit/dist/curated/command-groups.js +4 -0
- package/node_modules/@noodle-borg/agent-kit/dist/curated/error-fixes.js +1 -0
- package/node_modules/@noodle-borg/agent-kit/dist/generated/example-files.js +1 -1
- package/node_modules/@noodle-borg/agent-kit/dist/generated/surface.js +3 -0
- package/node_modules/@noodle-borg/agent-kit/dist/skill-embedded-assistant-ref.js +6 -0
- package/node_modules/@noodle-borg/agent-kit/package.json +1 -1
- package/node_modules/@noodle-borg/app-audit/dist/audit-embedded-assistant.js +2 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-configuration.js +2 -1
- package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-model-context.js +9 -4
- package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-turn-context.d.ts +16 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-turn-context.js +19 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/channel-coordinator.d.ts +145 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/channel-coordinator.js +378 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/channel-inbox.js +160 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/channel-provider-blocks.d.ts +18 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/channel-provider-blocks.js +69 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/channel-spend.js +82 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/channel-store.d.ts +42 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/channel-store.js +76 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/channel-types.d.ts +134 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/channel-types.js +72 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/channel-work.d.ts +20 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/channel-work.js +221 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/messaging-tool-dispatch.d.ts +15 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/messaging-tool-dispatch.js +34 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/model-request.d.ts +19 -1
- package/node_modules/@noodle-borg/assistant-gateway/dist/model-request.js +22 -6
- package/node_modules/@noodle-borg/assistant-gateway/dist/portable.d.ts +7 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/portable.js +7 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/postgres-assistant-appearance-store.js +83 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/postgres-assistant-elevation.js +151 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/postgres-assistant-interaction-writes.js +45 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/postgres-assistant-interactions.js +340 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/postgres-assistant.js +439 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/postgres-channel-store.js +102 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/postgres-continuity-store.js +159 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/postgres-elevation-store.js +110 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/postgres-embed-store.js +134 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/postgres.js +11 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/public-surface.d.ts +8 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/public-surface.js +23 -0
- package/node_modules/@noodle-borg/authoring/dist/assistant.d.ts +17 -2
- package/node_modules/@noodle-borg/authoring/dist/assistant.js +48 -19
- package/node_modules/@noodle-borg/authoring/dist/index.d.ts +1 -1
- package/node_modules/@noodle-borg/authoring/dist/index.js +1 -1
- package/node_modules/@noodle-borg/authoring/dist/load-authored.js +1 -1
- package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-channels.js +121 -0
- package/node_modules/@noodle-borg/cli-catalog/dist/index.js +1 -0
- package/node_modules/@noodle-borg/compiler/dist/artifact/config.js +1 -1
- package/node_modules/@noodle-borg/compiler/dist/errors.d.ts +1 -1
- package/node_modules/@noodle-borg/compiler/dist/manifest/schema.d.ts +72 -12
- package/node_modules/@noodle-borg/compiler/dist/manifest/schema.js +51 -27
- package/node_modules/@noodle-borg/compiler/dist/manifest/website-projection.d.ts +1 -0
- package/node_modules/@noodle-borg/compiler/dist/manifest/website-projection.js +19 -0
- package/node_modules/@noodle-borg/module-audit/dist/index.js +37 -0
- package/node_modules/@noodle-borg/module-audit/dist/postgres-audit.js +126 -0
- package/node_modules/@noodle-borg/module-audit/dist/route.js +66 -0
- package/node_modules/@noodle-borg/runtime/dist/managed-origins.js +8 -4
- package/node_modules/@noodle-borg/runtime/dist/postgres-state-handles.js +311 -0
- package/node_modules/@noodle-borg/runtime/dist/postgres.js +4 -0
- package/node_modules/@noodle-borg/service/dist/assistant-composition.js +18 -0
- package/node_modules/@noodle-borg/service/dist/build-info.js +5 -2
- package/node_modules/@noodle-borg/service/dist/business-information/source-ingestion-coordinator.js +7 -0
- package/node_modules/@noodle-borg/service/dist/channels/360dialog.js +233 -0
- package/node_modules/@noodle-borg/service/dist/channels/cipher.js +37 -0
- package/node_modules/@noodle-borg/service/dist/channels/composition.js +37 -0
- package/node_modules/@noodle-borg/service/dist/channels/inference-guard.js +53 -0
- package/node_modules/@noodle-borg/service/dist/channels/runtime.js +337 -0
- package/node_modules/@noodle-borg/service/dist/channels/worker-loop.js +50 -0
- package/node_modules/@noodle-borg/service/dist/routes/assistant-agent.js +62 -34
- package/node_modules/@noodle-borg/service/dist/routes/assistant-model-binding.js +6 -2
- package/node_modules/@noodle-borg/service/dist/routes/business-information-channels.js +14 -1
- package/node_modules/@noodle-borg/service/dist/routes/whatsapp.js +238 -0
- package/node_modules/@noodle-borg/service/dist/service.js +30 -32
- package/node_modules/@noodle-borg/service/package.json +1 -1
- package/node_modules/@noodle-borg/wire-contracts/dist/application-channels.d.ts +33 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/application-channels.js +9 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/index.d.ts +1 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/index.js +1 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/service-info.d.ts +2 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/service-info.js +10 -2
- package/node_modules/@noodle-borg/wire-contracts/dist/whatsapp.d.ts +426 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/whatsapp.js +213 -0
- 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 =
|
|
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,
|
|
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 (
|
|
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 =
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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({
|
|
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
|
|
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,
|