@datalayer/agent-runtimes 1.5.44 → 1.5.46
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-embed/chunks/{ChatView-kCmBbnDa.js → ChatView-Dy-IgGFL.js} +1 -1
- package/dist-embed/datalayer-app-main.js +99 -94
- package/lib/apps/plugins/chat/ChatView.js +2 -2
- package/lib/chat/ChatFloating.js +1 -1
- package/lib/chat/base/ChatBase.js +23 -8
- package/lib/chat/messages/ChatMessageList.d.ts +7 -1
- package/lib/chat/messages/ChatMessageList.js +8 -2
- package/lib/portable/hosts/__tests__/fixtures/lock-with-jupyter.json +40 -0
- package/lib/portable/hosts/__tests__/fixtures/npm-ls-with-jupyter.json +21 -0
- package/lib/portable/hosts/agentClient.d.ts +167 -0
- package/lib/portable/hosts/agentClient.js +346 -0
- package/lib/portable/hosts/approvalRenderers.d.ts +43 -0
- package/lib/portable/hosts/approvalRenderers.js +180 -0
- package/lib/portable/hosts/client.d.ts +95 -0
- package/lib/portable/hosts/client.js +174 -0
- package/lib/portable/hosts/contracts.d.ts +199 -0
- package/lib/portable/hosts/contracts.js +235 -0
- package/lib/portable/hosts/contracts.json +381 -0
- package/lib/portable/hosts/deadLetters.d.ts +41 -0
- package/lib/portable/hosts/deadLetters.js +124 -0
- package/lib/portable/hosts/deliveries.d.ts +76 -0
- package/lib/portable/hosts/deliveries.js +147 -0
- package/lib/portable/hosts/forbiddenGraph.d.ts +77 -0
- package/lib/portable/hosts/forbiddenGraph.js +174 -0
- package/lib/portable/hosts/health.d.ts +35 -0
- package/lib/portable/hosts/health.js +92 -0
- package/lib/portable/hosts/index.d.ts +40 -0
- package/lib/portable/hosts/index.js +45 -0
- package/lib/portable/hosts/observability.d.ts +67 -0
- package/lib/portable/hosts/observability.js +171 -0
- package/lib/portable/hosts/postgresQueue.d.ts +55 -0
- package/lib/portable/hosts/postgresQueue.js +217 -0
- package/lib/portable/hosts/queue.d.ts +47 -0
- package/lib/portable/hosts/queue.js +63 -0
- package/lib/portable/hosts/quotas.d.ts +81 -0
- package/lib/portable/hosts/quotas.js +147 -0
- package/lib/portable/hosts/rateLimit.d.ts +13 -0
- package/lib/portable/hosts/rateLimit.js +8 -0
- package/lib/portable/hosts/resolution.d.ts +54 -0
- package/lib/portable/hosts/resolution.js +46 -0
- package/lib/portable/hosts/schema.d.ts +19 -0
- package/lib/portable/hosts/schema.js +81 -0
- package/lib/portable/hosts/secretCipher.d.ts +23 -0
- package/lib/portable/hosts/secretCipher.js +45 -0
- package/lib/portable/hosts/sessionUid.d.ts +7 -0
- package/lib/portable/hosts/sessionUid.js +28 -0
- package/lib/portable/hosts/turnState.d.ts +45 -0
- package/lib/portable/hosts/turnState.js +113 -0
- package/lib/portable/index.d.ts +2 -0
- package/lib/portable/index.js +3 -0
- package/lib/portable/semantic-events.json +480 -0
- package/lib/portable/semantic.d.ts +78 -0
- package/lib/portable/semantic.js +100 -0
- package/lib/portable/semanticOfAgUi.d.ts +41 -0
- package/lib/portable/semanticOfAgUi.js +108 -0
- package/lib/protocols/A2AAdapter.js +19 -0
- package/lib/protocols/VercelAIAdapter.js +11 -0
- package/lib/specs/voices.js +62 -0
- package/lib/test-setup.js +17 -14
- package/lib/types/chat.d.ts +10 -0
- package/lib/voice/ComposerVoice.js +12 -3
- package/lib/voice/captions.d.ts +31 -0
- package/lib/voice/captions.js +156 -0
- package/lib/voice/handsFree.d.ts +12 -0
- package/lib/voice/handsFree.js +18 -0
- package/lib/voice/serverHearing.d.ts +29 -0
- package/lib/voice/serverHearing.js +78 -2
- package/package.json +1 -1
- package/scripts/voice/measure.py +77 -17
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2025-2026 Datalayer, Inc.
|
|
3
|
+
* Distributed under the terms of the Modified BSD License.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* A chat host's turns on Datalayer: the host credential exchange, a turn
|
|
7
|
+
* started or sent on the deployment's session API (idempotent by the host's
|
|
8
|
+
* event id), its AG-UI stream reduced to semantic events, and its session
|
|
9
|
+
* mapped to the host's thread so approvals and completions are delivered
|
|
10
|
+
* (SLACK §4.3, DISCORD §4.3). What is the host's own (its thread key, the
|
|
11
|
+
* session uid, the place, the delivery target, its words) is injected as a
|
|
12
|
+
* `HostShape`. Moved from the Slack service (plans/DISCORD.md DC-004).
|
|
13
|
+
*
|
|
14
|
+
* @module portable/hosts/agentClient
|
|
15
|
+
*/
|
|
16
|
+
import { z } from 'zod';
|
|
17
|
+
import { AgUiFrames } from '../agui';
|
|
18
|
+
import { semanticOf, turnOf } from '../semanticOfAgUi';
|
|
19
|
+
import { HostRefusedError } from './client';
|
|
20
|
+
import { exchangeSchema, HOST_KEY_HEADER, IDEMPOTENCY_HEADER, } from './contracts';
|
|
21
|
+
/**
|
|
22
|
+
* The host credential exchange (SLACK §4.3, 1): the installation's key and
|
|
23
|
+
* the host user it speaks for, exchanged at ai-agents for a fifteen-minute
|
|
24
|
+
* host token of the linked person and one deployment.
|
|
25
|
+
*/
|
|
26
|
+
export class HostKeyCredentials {
|
|
27
|
+
apiUrl;
|
|
28
|
+
hostKeyFor;
|
|
29
|
+
shape;
|
|
30
|
+
fetchImplementation;
|
|
31
|
+
constructor(apiUrl, hostKeyFor, shape, fetchImplementation = fetch) {
|
|
32
|
+
this.apiUrl = apiUrl;
|
|
33
|
+
this.hostKeyFor = hostKeyFor;
|
|
34
|
+
this.shape = shape;
|
|
35
|
+
this.fetchImplementation = fetchImplementation;
|
|
36
|
+
}
|
|
37
|
+
async exchange(request) {
|
|
38
|
+
const hostKey = await this.hostKeyFor(request);
|
|
39
|
+
if (!hostKey) {
|
|
40
|
+
throw new Error(this.shape.notBound);
|
|
41
|
+
}
|
|
42
|
+
return this.exchangeFor({
|
|
43
|
+
deploymentUid: request.applicationId,
|
|
44
|
+
externalUserId: request.externalUserId,
|
|
45
|
+
hostKey,
|
|
46
|
+
place: this.shape.placeOf(request),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
/** The exchange for one host user and deployment, with a key in hand (a delivery, an action). */
|
|
50
|
+
async exchangeFor(input) {
|
|
51
|
+
const response = await this.fetchImplementation(`${this.apiUrl}/api/ai-agents/v1/apps/hosts/self/token`, {
|
|
52
|
+
body: JSON.stringify({
|
|
53
|
+
deployment_uid: input.deploymentUid,
|
|
54
|
+
external_user_id: input.externalUserId,
|
|
55
|
+
...(input.place ? { place: input.place } : {}),
|
|
56
|
+
}),
|
|
57
|
+
headers: {
|
|
58
|
+
'content-type': 'application/json',
|
|
59
|
+
[HOST_KEY_HEADER]: input.hostKey,
|
|
60
|
+
},
|
|
61
|
+
method: 'POST',
|
|
62
|
+
});
|
|
63
|
+
if (!response.ok) {
|
|
64
|
+
const body = (await response.json().catch(() => ({})));
|
|
65
|
+
throw new HostRefusedError(response.status, typeof body.detail === 'string' ? body.detail : 'refused', 'Datalayer refused the host credential exchange');
|
|
66
|
+
}
|
|
67
|
+
return exchangeSchema.parse(await response.json());
|
|
68
|
+
}
|
|
69
|
+
async forTurn(request) {
|
|
70
|
+
return hostSessionOf(await this.exchange(request));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export function hostSessionOf(exchanged) {
|
|
74
|
+
return {
|
|
75
|
+
accessToken: exchanged.access_token,
|
|
76
|
+
agent: exchanged.session_api.agent,
|
|
77
|
+
bindable: true,
|
|
78
|
+
deploymentUid: exchanged.deployment.uid,
|
|
79
|
+
sessionApiUrl: exchanged.session_api.url,
|
|
80
|
+
userToken: exchanged.user_token,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* A turn's credentials: the exchange when its installation holds a host
|
|
85
|
+
* key; otherwise, in development only, the developer's own (`fallback`);
|
|
86
|
+
* otherwise a refusal saying the installation is not connected.
|
|
87
|
+
*/
|
|
88
|
+
export class InstallationCredentials {
|
|
89
|
+
keyed;
|
|
90
|
+
fallback;
|
|
91
|
+
constructor(keyed, fallback) {
|
|
92
|
+
this.keyed = keyed;
|
|
93
|
+
this.fallback = fallback;
|
|
94
|
+
}
|
|
95
|
+
async forTurn(request) {
|
|
96
|
+
if (await this.keyed.hostKeyFor(request)) {
|
|
97
|
+
return this.keyed.forTurn(request);
|
|
98
|
+
}
|
|
99
|
+
if (this.fallback) {
|
|
100
|
+
return this.fallback.forTurn(request);
|
|
101
|
+
}
|
|
102
|
+
throw new Error(this.keyed.shape.notBound);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Development only: the developer's own Datalayer key, and their own kept
|
|
107
|
+
* deployment, read from ai-agents as its owner. Never in production.
|
|
108
|
+
*/
|
|
109
|
+
export class DevelopmentCredentials {
|
|
110
|
+
apiUrl;
|
|
111
|
+
token;
|
|
112
|
+
fetchImplementation;
|
|
113
|
+
constructor(apiUrl, token, fetchImplementation = fetch) {
|
|
114
|
+
this.apiUrl = apiUrl;
|
|
115
|
+
this.token = token;
|
|
116
|
+
this.fetchImplementation = fetchImplementation;
|
|
117
|
+
}
|
|
118
|
+
async forTurn(request) {
|
|
119
|
+
const response = await this.fetchImplementation(`${this.apiUrl}/api/ai-agents/v1/apps/deployments/${encodeURIComponent(request.applicationId)}`, { headers: { authorization: `Bearer ${this.token}` } });
|
|
120
|
+
if (!response.ok) {
|
|
121
|
+
throw new Error(`Datalayer did not answer the deployment (${response.status}).`);
|
|
122
|
+
}
|
|
123
|
+
const body = (await response.json());
|
|
124
|
+
const kept = body.deployment?.kept;
|
|
125
|
+
if (!kept?.url || !kept.agent_id) {
|
|
126
|
+
throw new Error('The deployment is not kept always on: turn it on under Ship.');
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
accessToken: this.token,
|
|
130
|
+
agent: kept.agent_id,
|
|
131
|
+
bindable: false,
|
|
132
|
+
deploymentUid: request.applicationId,
|
|
133
|
+
sessionApiUrl: `${kept.url.replace(/\/+$/, '')}/api/v1/apps/sessions`,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/** What `GET /api/v1/apps/current` answers that a host reads: the application's starters. */
|
|
138
|
+
const currentAppSchema = z.object({
|
|
139
|
+
starters: z.array(z.object({ label: z.string(), message: z.string() })),
|
|
140
|
+
});
|
|
141
|
+
/**
|
|
142
|
+
* An application's starters (SLACK S-104), read from its runtime as the
|
|
143
|
+
* linked person: the host credential exchange (no place: what is suggested
|
|
144
|
+
* is private to them), then `GET /api/v1/apps/current` beside the session
|
|
145
|
+
* API it answers. A runtime that does not answer starters is refused.
|
|
146
|
+
*/
|
|
147
|
+
export class HostStarters {
|
|
148
|
+
apiUrl;
|
|
149
|
+
keyed;
|
|
150
|
+
hostKeyOf;
|
|
151
|
+
fetchImplementation;
|
|
152
|
+
constructor(apiUrl, keyed, hostKeyOf, fetchImplementation = fetch) {
|
|
153
|
+
this.apiUrl = apiUrl;
|
|
154
|
+
this.keyed = keyed;
|
|
155
|
+
this.hostKeyOf = hostKeyOf;
|
|
156
|
+
this.fetchImplementation = fetchImplementation;
|
|
157
|
+
}
|
|
158
|
+
async startersOf(key, externalUserId, applicationId) {
|
|
159
|
+
const hostKey = await this.hostKeyOf(key);
|
|
160
|
+
if (!hostKey) {
|
|
161
|
+
throw new Error(this.keyed.shape.notBound);
|
|
162
|
+
}
|
|
163
|
+
const exchanged = await this.keyed.exchangeFor({
|
|
164
|
+
deploymentUid: applicationId,
|
|
165
|
+
externalUserId,
|
|
166
|
+
hostKey,
|
|
167
|
+
});
|
|
168
|
+
const sessions = exchanged.session_api.url.replace(/\/+$/, '');
|
|
169
|
+
assertTrustedStreamUrl(this.apiUrl, sessions);
|
|
170
|
+
if (!sessions.endsWith('/api/v1/apps/sessions')) {
|
|
171
|
+
throw new Error("Datalayer answered a session API that is not an application runtime's.");
|
|
172
|
+
}
|
|
173
|
+
const response = await this.fetchImplementation(`${sessions.slice(0, -'/sessions'.length)}/current`, { headers: { authorization: `Bearer ${exchanged.access_token}` } });
|
|
174
|
+
if (!response.ok) {
|
|
175
|
+
throw new HostRefusedError(response.status, 'refused', 'The application did not say what it suggests asking');
|
|
176
|
+
}
|
|
177
|
+
return currentAppSchema.parse(await response.json()).starters;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/** Each AG-UI event of a run's `text/event-stream` body, as it arrives (`AgUiFrames`). */
|
|
181
|
+
export async function* agUiEventsOf(body) {
|
|
182
|
+
const decoder = new TextDecoder();
|
|
183
|
+
const frames = new AgUiFrames();
|
|
184
|
+
const reader = body.getReader();
|
|
185
|
+
try {
|
|
186
|
+
for (;;) {
|
|
187
|
+
const { done, value } = await reader.read();
|
|
188
|
+
if (done) {
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
yield* frames.push(decoder.decode(value, { stream: true }));
|
|
192
|
+
}
|
|
193
|
+
yield* frames.push(decoder.decode());
|
|
194
|
+
yield* frames.end();
|
|
195
|
+
}
|
|
196
|
+
finally {
|
|
197
|
+
reader.releaseLock();
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* The host's Datalayer client: a turn is started or sent on the deployment's
|
|
202
|
+
* session API (idempotent by the host's event id, SLACK §4.3, 2), its AG-UI
|
|
203
|
+
* stream reduced to semantic events, and its session mapped to the host's
|
|
204
|
+
* thread so that approvals and completions are delivered (§4.3, 3).
|
|
205
|
+
*/
|
|
206
|
+
export class HttpAgentClient {
|
|
207
|
+
apiUrl;
|
|
208
|
+
credentials;
|
|
209
|
+
shape;
|
|
210
|
+
fetchImplementation;
|
|
211
|
+
hostKeyFor;
|
|
212
|
+
constructor(apiUrl, credentials, shape, fetchImplementation = fetch, hostKeyFor) {
|
|
213
|
+
this.apiUrl = apiUrl;
|
|
214
|
+
this.credentials = credentials;
|
|
215
|
+
this.shape = shape;
|
|
216
|
+
this.fetchImplementation = fetchImplementation;
|
|
217
|
+
this.hostKeyFor = hostKeyFor;
|
|
218
|
+
}
|
|
219
|
+
async startTurn(request) {
|
|
220
|
+
const host = await this.credentials.forTurn(request);
|
|
221
|
+
assertTrustedStreamUrl(this.apiUrl, host.sessionApiUrl);
|
|
222
|
+
const sessionId = request.sessionId ?? this.shape.sessionUidOf(request);
|
|
223
|
+
const headers = {
|
|
224
|
+
accept: 'text/event-stream',
|
|
225
|
+
authorization: `Bearer ${host.accessToken}`,
|
|
226
|
+
'content-type': 'application/json',
|
|
227
|
+
[IDEMPOTENCY_HEADER]: request.externalEventId,
|
|
228
|
+
};
|
|
229
|
+
const response = request.sessionId
|
|
230
|
+
? await this.fetchImplementation(`${host.sessionApiUrl}/${encodeURIComponent(sessionId)}/messages`, {
|
|
231
|
+
body: JSON.stringify({
|
|
232
|
+
external_event_id: request.externalEventId,
|
|
233
|
+
text: request.prompt,
|
|
234
|
+
}),
|
|
235
|
+
headers,
|
|
236
|
+
method: 'POST',
|
|
237
|
+
})
|
|
238
|
+
: await this.fetchImplementation(host.sessionApiUrl, {
|
|
239
|
+
body: JSON.stringify({
|
|
240
|
+
agent: host.agent,
|
|
241
|
+
deployment_uid: host.deploymentUid,
|
|
242
|
+
external_event_id: request.externalEventId,
|
|
243
|
+
opener: request.prompt,
|
|
244
|
+
session: sessionId,
|
|
245
|
+
...(host.userToken ? { user_token: host.userToken } : {}),
|
|
246
|
+
}),
|
|
247
|
+
headers,
|
|
248
|
+
method: 'POST',
|
|
249
|
+
});
|
|
250
|
+
if (!response.ok || !response.body) {
|
|
251
|
+
throw new Error(`Datalayer refused the turn (${response.status}).`);
|
|
252
|
+
}
|
|
253
|
+
const hostKey = host.bindable && this.hostKeyFor
|
|
254
|
+
? await this.hostKeyFor(request)
|
|
255
|
+
: undefined;
|
|
256
|
+
if (hostKey) {
|
|
257
|
+
await this.#bind(request, host, sessionId, hostKey);
|
|
258
|
+
}
|
|
259
|
+
const run = {};
|
|
260
|
+
return {
|
|
261
|
+
run,
|
|
262
|
+
sessionId,
|
|
263
|
+
updates: this.#updates(response.body, host, sessionId, run),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
async stopTurn(sessionId, request) {
|
|
267
|
+
const host = await this.credentials.forTurn(request);
|
|
268
|
+
assertTrustedStreamUrl(this.apiUrl, host.sessionApiUrl);
|
|
269
|
+
const response = await this.fetchImplementation(`${host.sessionApiUrl}/${encodeURIComponent(sessionId)}/stop`, {
|
|
270
|
+
headers: { authorization: `Bearer ${host.accessToken}` },
|
|
271
|
+
method: 'POST',
|
|
272
|
+
});
|
|
273
|
+
if (!response.ok && response.status !== 409) {
|
|
274
|
+
throw new Error(`Datalayer could not stop the turn (${response.status}).`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
async #bind(request, host, sessionId, hostKey) {
|
|
278
|
+
const response = await this.fetchImplementation(`${this.apiUrl}/api/ai-agents/v1/apps/hosts/sessions/${encodeURIComponent(sessionId)}`, {
|
|
279
|
+
// Handed back with every delivery: the thread, and the host user the
|
|
280
|
+
// host token speaks for, whom an approval waits on.
|
|
281
|
+
body: JSON.stringify({ target: this.shape.targetOf(request) }),
|
|
282
|
+
headers: {
|
|
283
|
+
authorization: `Bearer ${host.accessToken}`,
|
|
284
|
+
'content-type': 'application/json',
|
|
285
|
+
[HOST_KEY_HEADER]: hostKey,
|
|
286
|
+
},
|
|
287
|
+
method: 'PUT',
|
|
288
|
+
});
|
|
289
|
+
if (!response.ok) {
|
|
290
|
+
throw new Error(`Datalayer did not map the thread to its session (${response.status}).`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
async *#updates(body, host, sessionId, run) {
|
|
294
|
+
const tools = new Map();
|
|
295
|
+
for await (const event of agUiEventsOf(body)) {
|
|
296
|
+
const turn = turnOf(event);
|
|
297
|
+
if (turn?.turn) {
|
|
298
|
+
run.id = turn.turn;
|
|
299
|
+
}
|
|
300
|
+
if (turn?.replayed) {
|
|
301
|
+
// A retry: nothing ran again; what the turn said is the session's.
|
|
302
|
+
yield* this.#replayed(host, sessionId, turn.state);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
yield* semanticOf(event, tools);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
async *#replayed(host, sessionId, state) {
|
|
309
|
+
if (state === 'running') {
|
|
310
|
+
yield { kind: 'progress', label: 'Still working on this message…' };
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const text = await lastAnswer(this.fetchImplementation, host, sessionId);
|
|
314
|
+
if (text === null) {
|
|
315
|
+
yield {
|
|
316
|
+
kind: 'error',
|
|
317
|
+
message: 'This message was already answered in Datalayer.',
|
|
318
|
+
};
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
yield { kind: 'completion', text };
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* The session's last answer, read with the host token (what a replayed turn
|
|
326
|
+
* and a `turn.completed` delivery show); `null` when the session is not read.
|
|
327
|
+
*/
|
|
328
|
+
export async function lastAnswer(fetchImplementation, host, sessionId) {
|
|
329
|
+
const response = await fetchImplementation(`${host.sessionApiUrl}/${encodeURIComponent(sessionId)}/messages`, { headers: { authorization: `Bearer ${host.accessToken}` } });
|
|
330
|
+
if (!response.ok) {
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
const { messages } = (await response.json());
|
|
334
|
+
const last = [...(messages ?? [])]
|
|
335
|
+
.reverse()
|
|
336
|
+
.find(message => message.role === 'assistant');
|
|
337
|
+
return typeof last?.content === 'string' ? last.content : undefined;
|
|
338
|
+
}
|
|
339
|
+
/** A session API or stream URL Datalayer answered: HTTPS, on Datalayer's own origin. */
|
|
340
|
+
export function assertTrustedStreamUrl(baseUrl, streamUrl) {
|
|
341
|
+
const base = new URL(baseUrl);
|
|
342
|
+
const stream = new URL(streamUrl);
|
|
343
|
+
if (stream.protocol !== 'https:' || stream.origin !== base.origin) {
|
|
344
|
+
throw new Error('Datalayer returned an untrusted stream URL.');
|
|
345
|
+
}
|
|
346
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ApprovalPresentation } from './contracts';
|
|
2
|
+
/**
|
|
3
|
+
* Typed approval renderers (plans/SLACK.md §2 rule 6, S-302; plans/DISCORD.md
|
|
4
|
+
* §3.6). A host offers Approve and Reject only when a renderer that knows the
|
|
5
|
+
* tool proves that every material argument is shown: each argument the server
|
|
6
|
+
* presented is named by the renderer, or the card fails closed. An unknown
|
|
7
|
+
* tool, an argument the renderer does not know, an attachment, and every
|
|
8
|
+
* destructive, financial or publishing action are reviewed in Datalayer
|
|
9
|
+
* (SLACK §15, 5). They render ai-agents' presentation v1, a different input
|
|
10
|
+
* from `../approvals.ts` and `../approvalCard.ts`. Moved from the Slack
|
|
11
|
+
* service (plans/DISCORD.md DC-004).
|
|
12
|
+
*
|
|
13
|
+
* @module portable/hosts/approvalRenderers
|
|
14
|
+
*/
|
|
15
|
+
export type RenderedField = Readonly<{
|
|
16
|
+
label: string;
|
|
17
|
+
value: string;
|
|
18
|
+
}>;
|
|
19
|
+
export type RenderedAction = Readonly<{
|
|
20
|
+
/** The renderer that knew the tool. */
|
|
21
|
+
renderer: string;
|
|
22
|
+
/** The fields shown, in order, each whole. */
|
|
23
|
+
fields: readonly RenderedField[];
|
|
24
|
+
/** Whether every argument is shown and nothing needs Datalayer. */
|
|
25
|
+
complete: boolean;
|
|
26
|
+
/** Why not, in words, when it is not. */
|
|
27
|
+
reasons: readonly string[];
|
|
28
|
+
}>;
|
|
29
|
+
type Renderer = Readonly<{
|
|
30
|
+
name: string;
|
|
31
|
+
matches(presentation: ApprovalPresentation): boolean;
|
|
32
|
+
render(presentation: ApprovalPresentation): RenderedAction;
|
|
33
|
+
}>;
|
|
34
|
+
/** Action classes that are decided in Datalayer only (§15, 5). */
|
|
35
|
+
export declare const DATALAYER_ONLY: Set<"unknown" | "read" | "write" | "send" | "delete" | "publish" | "execute" | "financial">;
|
|
36
|
+
/** The renderers, the first that matches wins: Datalayer-only before any other. */
|
|
37
|
+
export declare const RENDERERS: readonly Renderer[];
|
|
38
|
+
/**
|
|
39
|
+
* How a host shows an approval, by the renderer that knows its tool; with
|
|
40
|
+
* none, nothing is decided in the host.
|
|
41
|
+
*/
|
|
42
|
+
export declare function renderApproval(presentation: ApprovalPresentation): RenderedAction;
|
|
43
|
+
export {};
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2025-2026 Datalayer, Inc.
|
|
3
|
+
* Distributed under the terms of the Modified BSD License.
|
|
4
|
+
*/
|
|
5
|
+
/** Action classes that are decided in Datalayer only (§15, 5). */
|
|
6
|
+
export const DATALAYER_ONLY = new Set([
|
|
7
|
+
'delete',
|
|
8
|
+
'publish',
|
|
9
|
+
'financial',
|
|
10
|
+
'execute',
|
|
11
|
+
'unknown',
|
|
12
|
+
]);
|
|
13
|
+
const text = (value) => typeof value === 'string' ? value : JSON.stringify(value, null, 2);
|
|
14
|
+
const list = (value) => Array.isArray(value) ? value.map(text).join(', ') : text(value);
|
|
15
|
+
/** The arguments a renderer did not account for: each fails the card closed. */
|
|
16
|
+
function unknownKeys(presentation, known) {
|
|
17
|
+
return Object.keys(presentation.arguments).filter(key => !known.includes(key));
|
|
18
|
+
}
|
|
19
|
+
function rendered(name, presentation, fields, known, extra = []) {
|
|
20
|
+
const unknown = unknownKeys(presentation, known);
|
|
21
|
+
const reasons = [
|
|
22
|
+
...extra,
|
|
23
|
+
...(unknown.length
|
|
24
|
+
? [
|
|
25
|
+
`@Datalayer does not know how to show ${unknown.join(', ')} of this action: review it in Datalayer.`,
|
|
26
|
+
]
|
|
27
|
+
: []),
|
|
28
|
+
];
|
|
29
|
+
return { complete: reasons.length === 0, fields, reasons, renderer: name };
|
|
30
|
+
}
|
|
31
|
+
const MAIL_TOOL = /(^|[_.-])(send|draft)[_.-]?(e?mail|message)($|[_.-])|^gmail[_.-]send|^mail[_.-]send$/i;
|
|
32
|
+
const MAIL_KEYS = [
|
|
33
|
+
'to',
|
|
34
|
+
'cc',
|
|
35
|
+
'bcc',
|
|
36
|
+
'subject',
|
|
37
|
+
'body',
|
|
38
|
+
'html',
|
|
39
|
+
'attachments',
|
|
40
|
+
'reply_to',
|
|
41
|
+
];
|
|
42
|
+
/** Mail: every recipient, the subject, the whole body; an attachment goes to Datalayer. */
|
|
43
|
+
const mail = {
|
|
44
|
+
name: 'mail',
|
|
45
|
+
matches: presentation => presentation.action_class === 'send' &&
|
|
46
|
+
MAIL_TOOL.test(presentation.tool_name),
|
|
47
|
+
render(presentation) {
|
|
48
|
+
const args = presentation.arguments;
|
|
49
|
+
const fields = [];
|
|
50
|
+
const missing = [];
|
|
51
|
+
const extra = [];
|
|
52
|
+
for (const [key, label] of [
|
|
53
|
+
['to', 'To'],
|
|
54
|
+
['cc', 'Cc'],
|
|
55
|
+
['bcc', 'Bcc'],
|
|
56
|
+
['reply_to', 'Reply to'],
|
|
57
|
+
['subject', 'Subject'],
|
|
58
|
+
])
|
|
59
|
+
if (args[key] !== undefined && args[key] !== null && args[key] !== '')
|
|
60
|
+
fields.push({ label, value: list(args[key]) });
|
|
61
|
+
if (args.to === undefined || args.to === '' || args.to === null)
|
|
62
|
+
missing.push('its recipients');
|
|
63
|
+
if (args.subject === undefined)
|
|
64
|
+
missing.push('its subject');
|
|
65
|
+
if (args.html !== undefined && args.html !== null && args.html !== '')
|
|
66
|
+
extra.push('It sends formatted mail, which a chat cannot show as it will be read: review it in Datalayer.');
|
|
67
|
+
if (args.body === undefined && args.html === undefined)
|
|
68
|
+
missing.push('its body');
|
|
69
|
+
else if (args.body !== undefined)
|
|
70
|
+
fields.push({ label: 'Body', value: text(args.body) });
|
|
71
|
+
const attachments = Array.isArray(args.attachments)
|
|
72
|
+
? args.attachments
|
|
73
|
+
: args.attachments
|
|
74
|
+
? [args.attachments]
|
|
75
|
+
: [];
|
|
76
|
+
if (attachments.length > 0) {
|
|
77
|
+
fields.push({ label: 'Attachments', value: list(attachments) });
|
|
78
|
+
extra.push('It sends attachments, which are reviewed in Datalayer, where you can open them.');
|
|
79
|
+
}
|
|
80
|
+
if (missing.length)
|
|
81
|
+
extra.push(`The request does not say ${missing.join(', ')}: review it in Datalayer.`);
|
|
82
|
+
return rendered('mail', presentation, fields, MAIL_KEYS, extra);
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
const WRITE_TOOL = /(^|[_.-])(write|update|edit|append|create|save|set|rename|move)[_.-]?/i;
|
|
86
|
+
const WRITE_KEYS = [
|
|
87
|
+
'path',
|
|
88
|
+
'file',
|
|
89
|
+
'filename',
|
|
90
|
+
'name',
|
|
91
|
+
'title',
|
|
92
|
+
'content',
|
|
93
|
+
'text',
|
|
94
|
+
'new_path',
|
|
95
|
+
'destination',
|
|
96
|
+
'mode',
|
|
97
|
+
];
|
|
98
|
+
/** A write: where, and the whole of what is written. */
|
|
99
|
+
const write = {
|
|
100
|
+
name: 'write',
|
|
101
|
+
matches: presentation => presentation.action_class === 'write' &&
|
|
102
|
+
WRITE_TOOL.test(presentation.tool_name),
|
|
103
|
+
render(presentation) {
|
|
104
|
+
const args = presentation.arguments;
|
|
105
|
+
const fields = [];
|
|
106
|
+
for (const [key, label] of [
|
|
107
|
+
['path', 'Where'],
|
|
108
|
+
['file', 'Where'],
|
|
109
|
+
['filename', 'Where'],
|
|
110
|
+
['name', 'Name'],
|
|
111
|
+
['title', 'Title'],
|
|
112
|
+
['new_path', 'Moved to'],
|
|
113
|
+
['destination', 'Moved to'],
|
|
114
|
+
['mode', 'How'],
|
|
115
|
+
['content', 'What is written'],
|
|
116
|
+
['text', 'What is written'],
|
|
117
|
+
])
|
|
118
|
+
if (args[key] !== undefined)
|
|
119
|
+
fields.push({ label, value: text(args[key]) });
|
|
120
|
+
const extra = fields.length === 0
|
|
121
|
+
? ['The request does not say what it writes: review it in Datalayer.']
|
|
122
|
+
: [];
|
|
123
|
+
return rendered('write', presentation, fields, WRITE_KEYS, extra);
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
/** Delete, publish, money and running code: shown, decided only in Datalayer (§15, 5). */
|
|
127
|
+
const datalayerOnly = {
|
|
128
|
+
name: 'datalayer-only',
|
|
129
|
+
matches: presentation => DATALAYER_ONLY.has(presentation.action_class),
|
|
130
|
+
render(presentation) {
|
|
131
|
+
const words = {
|
|
132
|
+
delete: 'Deleting is decided in Datalayer.',
|
|
133
|
+
execute: 'Running code is decided in Datalayer.',
|
|
134
|
+
financial: 'Moving money is decided in Datalayer.',
|
|
135
|
+
publish: 'Publishing is decided in Datalayer.',
|
|
136
|
+
unknown: '@Datalayer does not know this action: it is decided in Datalayer.',
|
|
137
|
+
};
|
|
138
|
+
return {
|
|
139
|
+
complete: false,
|
|
140
|
+
fields: presentation.fields.map(({ label, value }) => ({ label, value })),
|
|
141
|
+
reasons: [
|
|
142
|
+
words[presentation.action_class] ?? 'It is decided in Datalayer.',
|
|
143
|
+
],
|
|
144
|
+
renderer: 'datalayer-only',
|
|
145
|
+
};
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
/** A read: every argument, by its name, whole — nothing is changed or sent. */
|
|
149
|
+
const read = {
|
|
150
|
+
name: 'read',
|
|
151
|
+
matches: presentation => presentation.action_class === 'read',
|
|
152
|
+
render(presentation) {
|
|
153
|
+
const fields = Object.entries(presentation.arguments).map(([key, value]) => ({ label: key, value: text(value) }));
|
|
154
|
+
return rendered('read', presentation, fields, Object.keys(presentation.arguments));
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
/** The renderers, the first that matches wins: Datalayer-only before any other. */
|
|
158
|
+
export const RENDERERS = [
|
|
159
|
+
datalayerOnly,
|
|
160
|
+
mail,
|
|
161
|
+
write,
|
|
162
|
+
read,
|
|
163
|
+
];
|
|
164
|
+
/**
|
|
165
|
+
* How a host shows an approval, by the renderer that knows its tool; with
|
|
166
|
+
* none, nothing is decided in the host.
|
|
167
|
+
*/
|
|
168
|
+
export function renderApproval(presentation) {
|
|
169
|
+
const renderer = RENDERERS.find(one => one.matches(presentation));
|
|
170
|
+
if (!renderer)
|
|
171
|
+
return {
|
|
172
|
+
complete: false,
|
|
173
|
+
fields: presentation.fields.map(({ label, value }) => ({ label, value })),
|
|
174
|
+
reasons: [
|
|
175
|
+
`@Datalayer has no way yet to show ${presentation.tool_name} whole: review it in Datalayer.`,
|
|
176
|
+
],
|
|
177
|
+
renderer: 'none',
|
|
178
|
+
};
|
|
179
|
+
return renderer.render(presentation);
|
|
180
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { type ApprovalPresentation, type HostDelivery } from './contracts';
|
|
2
|
+
/** What ai-agents refused, with its status and its sentence. */
|
|
3
|
+
export declare class HostRefusedError extends Error {
|
|
4
|
+
readonly status: number;
|
|
5
|
+
readonly detail: string;
|
|
6
|
+
readonly what: string;
|
|
7
|
+
constructor(status: number, detail: string, what: string);
|
|
8
|
+
}
|
|
9
|
+
export type HostSelf = Readonly<{
|
|
10
|
+
accountUid: string;
|
|
11
|
+
channels: ReadonlyArray<{
|
|
12
|
+
deployment: string;
|
|
13
|
+
id: string;
|
|
14
|
+
state: 'allow' | 'deny';
|
|
15
|
+
}>;
|
|
16
|
+
defaultDeployment?: string;
|
|
17
|
+
deployments: ReadonlyArray<{
|
|
18
|
+
appName: string;
|
|
19
|
+
kept: boolean;
|
|
20
|
+
uid: string;
|
|
21
|
+
}>;
|
|
22
|
+
directOnly: readonly string[];
|
|
23
|
+
paused: boolean;
|
|
24
|
+
}>;
|
|
25
|
+
export type BoundHost = Readonly<{
|
|
26
|
+
defaultDeployment?: string;
|
|
27
|
+
hostKey: string;
|
|
28
|
+
hostUid: string;
|
|
29
|
+
name?: string;
|
|
30
|
+
}>;
|
|
31
|
+
/**
|
|
32
|
+
* The generic host routes of ai-agents (plans/SLACK.md §4.3, plans/DISCORD.md
|
|
33
|
+
* §4.3) a host calls besides the credential exchange: as the installation,
|
|
34
|
+
* with its key in `X-Datalayer-Host-Key`, or as a person, with the host token
|
|
35
|
+
* the exchange answered. Any host speaks them.
|
|
36
|
+
*/
|
|
37
|
+
export declare class DatalayerHostClient {
|
|
38
|
+
#private;
|
|
39
|
+
readonly apiUrl: string;
|
|
40
|
+
readonly fetchImplementation: typeof fetch;
|
|
41
|
+
constructor(apiUrl: string, fetchImplementation?: typeof fetch);
|
|
42
|
+
/** Bind the installation its owner set up, with the code their browser carried (§3.1). */
|
|
43
|
+
bind(input: {
|
|
44
|
+
code: string;
|
|
45
|
+
externalId: string;
|
|
46
|
+
name?: string;
|
|
47
|
+
}): Promise<BoundHost>;
|
|
48
|
+
/** The installation was removed from its host: it ends at Datalayer (revocation). */
|
|
49
|
+
uninstalled(hostKey: string): Promise<void>;
|
|
50
|
+
/**
|
|
51
|
+
* The installation as Datalayer keeps it: the deployments it allows (with
|
|
52
|
+
* their application's name and whether each is kept on), the default, the
|
|
53
|
+
* channel policy and whether it is paused.
|
|
54
|
+
*/
|
|
55
|
+
self(hostKey: string): Promise<HostSelf>;
|
|
56
|
+
/** The host unlinks one of its users: how many links ended. */
|
|
57
|
+
unlink(hostKey: string, externalUserId: string): Promise<number>;
|
|
58
|
+
/** A one-use code a host user redeems, signed in to Datalayer, to link their account. */
|
|
59
|
+
linkCode(hostKey: string, externalUserId: string, externalUserName?: string): Promise<{
|
|
60
|
+
code: string;
|
|
61
|
+
expiresIn: number;
|
|
62
|
+
}>;
|
|
63
|
+
/** Whether a host user is linked, and to which principal. */
|
|
64
|
+
linkState(hostKey: string, externalUserId: string): Promise<{
|
|
65
|
+
linked: boolean;
|
|
66
|
+
principalUid?: string;
|
|
67
|
+
}>;
|
|
68
|
+
/** Where deliveries are pushed: answered the secret they are signed with, once. */
|
|
69
|
+
setWebhook(hostKey: string, url: string): Promise<{
|
|
70
|
+
signingSecret: string;
|
|
71
|
+
version: number;
|
|
72
|
+
}>;
|
|
73
|
+
/** What the installation has not acknowledged, oldest first: pushed or not. */
|
|
74
|
+
outbox(hostKey: string, limit?: number): Promise<HostDelivery[]>;
|
|
75
|
+
acknowledge(hostKey: string, deliveryUid: string): Promise<void>;
|
|
76
|
+
/**
|
|
77
|
+
* *Open in Datalayer* (S-305): a one-use code for the host token's person
|
|
78
|
+
* that opens one session or approval after sign-in. Answers the landing's
|
|
79
|
+
* path that redeems it; never a bearer token in a URL.
|
|
80
|
+
*/
|
|
81
|
+
handoff(token: string, kind: 'session' | 'approval', target: string): Promise<{
|
|
82
|
+
path: string;
|
|
83
|
+
expiresIn: number;
|
|
84
|
+
}>;
|
|
85
|
+
/** How an approval is shown, read as the person it waits on (their host token). */
|
|
86
|
+
presentation(token: string, approvalId: string): Promise<ApprovalPresentation>;
|
|
87
|
+
/** Decide an approval as its person, on the very presentation shown (its digest). */
|
|
88
|
+
decide(token: string, approvalId: string, decision: {
|
|
89
|
+
approve: boolean;
|
|
90
|
+
digest: string;
|
|
91
|
+
note?: string;
|
|
92
|
+
}): Promise<{
|
|
93
|
+
status: string;
|
|
94
|
+
}>;
|
|
95
|
+
}
|