@letta-ai/letta-code 0.30.17 → 0.30.19
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/gateway-core.js +572 -31
- package/dist/gateway-core.js.map +6 -4
- package/dist/mcp-client.js +2 -2
- package/dist/mcp-client.js.map +1 -1
- package/dist/types/agent/message.d.ts +0 -2
- package/dist/types/agent/message.d.ts.map +1 -1
- package/dist/types/channels/control-request-coordinator.d.ts +42 -0
- package/dist/types/channels/control-request-coordinator.d.ts.map +1 -0
- package/dist/types/channels/gateway-core.d.ts +1 -0
- package/dist/types/channels/gateway-core.d.ts.map +1 -1
- package/dist/types/channels/interactive.d.ts +13 -0
- package/dist/types/channels/interactive.d.ts.map +1 -0
- package/dist/types/gateway-core.d.ts +3 -0
- package/dist/types/gateway-core.d.ts.map +1 -1
- package/dist/types/tools/impl/bash.d.ts.map +1 -1
- package/dist/types/tools/impl/foreground-sleep.d.ts +13 -0
- package/dist/types/tools/impl/foreground-sleep.d.ts.map +1 -0
- package/dist/types/types/app-server-protocol.d.ts +1 -0
- package/dist/types/types/app-server-protocol.d.ts.map +1 -1
- package/dist/types/types/protocol_v2.d.ts +3 -3
- package/dist/types/types/protocol_v2.d.ts.map +1 -1
- package/dist/types/types/queue-update-protocol.d.ts +5 -0
- package/dist/types/types/queue-update-protocol.d.ts.map +1 -0
- package/dist/types/types/runtime-scope.d.ts +0 -2
- package/dist/types/types/runtime-scope.d.ts.map +1 -1
- package/dist/types/websocket/listener/protocol-outbound.d.ts +5 -7
- package/dist/types/websocket/listener/protocol-outbound.d.ts.map +1 -1
- package/dist/types/websocket/listener/runtime.d.ts.map +1 -1
- package/dist/types/websocket/listener/scope.d.ts +0 -1
- package/dist/types/websocket/listener/scope.d.ts.map +1 -1
- package/dist/types/websocket/listener/turn-lifecycle.d.ts +0 -3
- package/dist/types/websocket/listener/turn-lifecycle.d.ts.map +1 -1
- package/dist/types/websocket/listener/types.d.ts +0 -4
- package/dist/types/websocket/listener/types.d.ts.map +1 -1
- package/letta.js +1349 -837
- package/package.json +1 -1
- package/scripts/source-file-size-baseline.json +2 -2
package/dist/gateway-core.js
CHANGED
|
@@ -1,3 +1,490 @@
|
|
|
1
|
+
// src/channels/interactive.ts
|
|
2
|
+
function normalizeWhitespace(text) {
|
|
3
|
+
return text.replace(/\s+/g, " ").trim();
|
|
4
|
+
}
|
|
5
|
+
function isAffirmativeResponse(text) {
|
|
6
|
+
const normalized = normalizeWhitespace(text).toLowerCase();
|
|
7
|
+
return [
|
|
8
|
+
"approve",
|
|
9
|
+
"approved",
|
|
10
|
+
"allow",
|
|
11
|
+
"yes",
|
|
12
|
+
"y",
|
|
13
|
+
"ok",
|
|
14
|
+
"okay",
|
|
15
|
+
"continue",
|
|
16
|
+
"go ahead",
|
|
17
|
+
"looks good",
|
|
18
|
+
"lgtm",
|
|
19
|
+
"sgtm",
|
|
20
|
+
"ship it"
|
|
21
|
+
].includes(normalized);
|
|
22
|
+
}
|
|
23
|
+
function stripApprovalPrefix(text) {
|
|
24
|
+
return normalizeWhitespace(text.replace(/^(approve|allow|yes|y|ok|okay|deny|reject|no|n)\s*[:-]?\s*/i, ""));
|
|
25
|
+
}
|
|
26
|
+
function summarizeControlRequestInput(input) {
|
|
27
|
+
const serialized = JSON.stringify(input, null, 2);
|
|
28
|
+
if (!serialized || serialized === "{}") {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
if (serialized.length <= 1200) {
|
|
32
|
+
return serialized;
|
|
33
|
+
}
|
|
34
|
+
return `${serialized.slice(0, 1197).trimEnd()}...`;
|
|
35
|
+
}
|
|
36
|
+
function buildQuestionPrompt(question, index) {
|
|
37
|
+
const lines = [
|
|
38
|
+
`${index + 1}. ${question.question ?? `Question ${index + 1}`}`
|
|
39
|
+
];
|
|
40
|
+
const options = question.options ?? [];
|
|
41
|
+
options.forEach((option, optionIndex) => {
|
|
42
|
+
const label = option.label?.trim() || `Option ${optionIndex + 1}`;
|
|
43
|
+
const description = option.description?.trim();
|
|
44
|
+
lines.push(description ? ` ${optionIndex + 1}) ${label} — ${description}` : ` ${optionIndex + 1}) ${label}`);
|
|
45
|
+
});
|
|
46
|
+
if (question.multiSelect) {
|
|
47
|
+
lines.push(" Choose one or more options. Separate multiple answers with commas.");
|
|
48
|
+
}
|
|
49
|
+
return lines;
|
|
50
|
+
}
|
|
51
|
+
function matchQuestionOption(question, text) {
|
|
52
|
+
const trimmed = normalizeWhitespace(text);
|
|
53
|
+
const options = question.options ?? [];
|
|
54
|
+
if (!trimmed || options.length === 0) {
|
|
55
|
+
return trimmed;
|
|
56
|
+
}
|
|
57
|
+
const numberMatch = trimmed.match(/^(\d+)$/);
|
|
58
|
+
if (numberMatch?.[1]) {
|
|
59
|
+
const option = options[Number(numberMatch[1]) - 1];
|
|
60
|
+
if (option?.label?.trim()) {
|
|
61
|
+
return option.label.trim();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const exactLabel = options.find((option) => option.label && normalizeWhitespace(option.label).toLowerCase() === trimmed.toLowerCase());
|
|
65
|
+
if (exactLabel?.label?.trim()) {
|
|
66
|
+
return exactLabel.label.trim();
|
|
67
|
+
}
|
|
68
|
+
const trimmedLower = trimmed.toLowerCase();
|
|
69
|
+
const exactAlias = options.find((option) => optionAliases(option).some((alias) => normalizeWhitespace(alias).toLowerCase() === trimmedLower));
|
|
70
|
+
if (exactAlias?.label?.trim()) {
|
|
71
|
+
return exactAlias.label.trim();
|
|
72
|
+
}
|
|
73
|
+
if (/^(recommended|default|defaults)$/i.test(trimmed)) {
|
|
74
|
+
const recommended = options.find((option) => {
|
|
75
|
+
const label = option.label?.toLowerCase() ?? "";
|
|
76
|
+
const description = option.description?.toLowerCase() ?? "";
|
|
77
|
+
return label.includes("recommended") || description.includes("recommended");
|
|
78
|
+
});
|
|
79
|
+
if (recommended?.label?.trim()) {
|
|
80
|
+
return recommended.label.trim();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return trimmed;
|
|
84
|
+
}
|
|
85
|
+
function escapeRegExp(text) {
|
|
86
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
87
|
+
}
|
|
88
|
+
function optionAliases(option) {
|
|
89
|
+
const aliases = new Set;
|
|
90
|
+
const label = normalizeWhitespace(option.label ?? "");
|
|
91
|
+
if (label) {
|
|
92
|
+
aliases.add(label);
|
|
93
|
+
const withoutParenthetical = normalizeWhitespace(label.replace(/\s*\([^)]*\)\s*/g, " "));
|
|
94
|
+
if (withoutParenthetical) {
|
|
95
|
+
aliases.add(withoutParenthetical);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return Array.from(aliases).filter((alias) => alias.length >= 3);
|
|
99
|
+
}
|
|
100
|
+
function containsPhrase(text, phrase) {
|
|
101
|
+
const normalizedText = normalizeWhitespace(text);
|
|
102
|
+
const normalizedPhrase = normalizeWhitespace(phrase);
|
|
103
|
+
if (!normalizedText || !normalizedPhrase) {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
const escapedPhrase = escapeRegExp(normalizedPhrase).replace(/\s+/g, "\\s+");
|
|
107
|
+
return new RegExp(`(^|\\W)${escapedPhrase}(?=$|\\W)`, "i").test(normalizedText);
|
|
108
|
+
}
|
|
109
|
+
function inferQuestionAnswerFromText(question, rawText) {
|
|
110
|
+
const options = question.options ?? [];
|
|
111
|
+
const matches = options.filter((option) => optionAliases(option).some((alias) => containsPhrase(rawText, alias))).map((option) => option.label?.trim()).filter((label) => Boolean(label));
|
|
112
|
+
const uniqueMatches = Array.from(new Set(matches));
|
|
113
|
+
if (uniqueMatches.length === 1) {
|
|
114
|
+
return uniqueMatches[0] ?? null;
|
|
115
|
+
}
|
|
116
|
+
const normalized = normalizeWhitespace(rawText).toLowerCase();
|
|
117
|
+
if (/\b(recommended|default|defaults|you decide|whatever you recommend)\b/.test(normalized)) {
|
|
118
|
+
const recommended = options.find((option) => {
|
|
119
|
+
const label = option.label?.toLowerCase() ?? "";
|
|
120
|
+
const description = option.description?.toLowerCase() ?? "";
|
|
121
|
+
return label.includes("recommended") || description.includes("recommended");
|
|
122
|
+
});
|
|
123
|
+
if (recommended?.label?.trim()) {
|
|
124
|
+
return recommended.label.trim();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
function matchQuestionAnswer(question, text) {
|
|
130
|
+
if (!question.multiSelect) {
|
|
131
|
+
return matchQuestionOption(question, text);
|
|
132
|
+
}
|
|
133
|
+
const normalized = normalizeWhitespace(text);
|
|
134
|
+
if (!normalized) {
|
|
135
|
+
return normalized;
|
|
136
|
+
}
|
|
137
|
+
const selections = normalized.replace(/\band\b/gi, ",").split(/\s*(?:,|\/|;)\s*/).map((entry) => normalizeWhitespace(entry)).filter(Boolean);
|
|
138
|
+
if (selections.length <= 1) {
|
|
139
|
+
return matchQuestionOption(question, normalized);
|
|
140
|
+
}
|
|
141
|
+
const matchedSelections = Array.from(new Set(selections.map((selection) => matchQuestionOption(question, selection)).filter(Boolean)));
|
|
142
|
+
return matchedSelections.length > 0 ? matchedSelections.join(", ") : normalized;
|
|
143
|
+
}
|
|
144
|
+
function parseNumberedAnswers(rawText, questions) {
|
|
145
|
+
const matches = Array.from(rawText.matchAll(/(?:^|\n)\s*(\d+)[).:-]\s*(.+?)(?=(?:\n\s*\d+[).:-]\s*)|$)/gs));
|
|
146
|
+
if (matches.length === 0) {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
const answers = {};
|
|
150
|
+
for (const match of matches) {
|
|
151
|
+
const questionIndex = Number(match[1]) - 1;
|
|
152
|
+
const question = questions[questionIndex];
|
|
153
|
+
const answerText = match[2]?.trim();
|
|
154
|
+
if (!question?.question || !answerText) {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
answers[question.question] = matchQuestionAnswer(question, answerText);
|
|
158
|
+
}
|
|
159
|
+
return Object.keys(answers).length > 0 ? answers : null;
|
|
160
|
+
}
|
|
161
|
+
function parsePositionalAnswers(rawText, questions) {
|
|
162
|
+
const lines = rawText.split(/\r?\n/).map((line) => normalizeWhitespace(line)).filter(Boolean);
|
|
163
|
+
if (lines.length === questions.length) {
|
|
164
|
+
const answers = {};
|
|
165
|
+
questions.forEach((question, index) => {
|
|
166
|
+
if (question.question) {
|
|
167
|
+
answers[question.question] = matchQuestionAnswer(question, lines[index] ?? "");
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
return answers;
|
|
171
|
+
}
|
|
172
|
+
const normalized = normalizeWhitespace(rawText);
|
|
173
|
+
const compactParts = normalized.split(/\s*(?:,|\/|;)\s*/).map((part) => normalizeWhitespace(part)).filter(Boolean);
|
|
174
|
+
if (compactParts.length === questions.length && compactParts.every((part) => /^\d+$/.test(part))) {
|
|
175
|
+
const answers = {};
|
|
176
|
+
questions.forEach((question, index) => {
|
|
177
|
+
if (question.question) {
|
|
178
|
+
answers[question.question] = matchQuestionAnswer(question, compactParts[index] ?? "");
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
return answers;
|
|
182
|
+
}
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
function parseFlexibleMultiQuestionAnswers(rawText, questions) {
|
|
186
|
+
const normalized = normalizeWhitespace(rawText);
|
|
187
|
+
if (!normalized) {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
const positionalAnswers = parsePositionalAnswers(rawText, questions);
|
|
191
|
+
if (positionalAnswers) {
|
|
192
|
+
return positionalAnswers;
|
|
193
|
+
}
|
|
194
|
+
const answers = {};
|
|
195
|
+
for (const question of questions) {
|
|
196
|
+
if (!question.question) {
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
const inferred = inferQuestionAnswerFromText(question, rawText);
|
|
200
|
+
if (inferred) {
|
|
201
|
+
answers[question.question] = inferred;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const hasInferredAnswers = Object.keys(answers).length > 0;
|
|
205
|
+
for (const question of questions) {
|
|
206
|
+
if (!question.question || Object.hasOwn(answers, question.question)) {
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
answers[question.question] = hasInferredAnswers ? `Not specified. Full user reply: ${normalized}` : normalized;
|
|
210
|
+
}
|
|
211
|
+
return Object.keys(answers).length > 0 ? answers : null;
|
|
212
|
+
}
|
|
213
|
+
function buildAllowResponse(requestId, decision) {
|
|
214
|
+
return {
|
|
215
|
+
request_id: requestId,
|
|
216
|
+
decision
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
function buildDenyResponse(requestId, message) {
|
|
220
|
+
return {
|
|
221
|
+
request_id: requestId,
|
|
222
|
+
decision: {
|
|
223
|
+
behavior: "deny",
|
|
224
|
+
message
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
function getAskUserQuestionInput(input) {
|
|
229
|
+
return input;
|
|
230
|
+
}
|
|
231
|
+
function formatAskUserQuestionPrompt(event) {
|
|
232
|
+
const input = getAskUserQuestionInput(event.input);
|
|
233
|
+
const questions = (input.questions ?? []).filter((question) => normalizeWhitespace(question.question ?? ""));
|
|
234
|
+
const lines = [
|
|
235
|
+
"SYSTEM MESSAGE — reply required to continue",
|
|
236
|
+
"",
|
|
237
|
+
...questions.flatMap((question, index) => buildQuestionPrompt(question, index)),
|
|
238
|
+
""
|
|
239
|
+
];
|
|
240
|
+
if (questions.length <= 1) {
|
|
241
|
+
const singleQuestion = questions[0];
|
|
242
|
+
lines.push(singleQuestion?.multiSelect ? "Reply with one or more option numbers/labels separated by commas, or just send a freeform answer in your next message." : "Reply with an option number/label, or just send a freeform answer in your next message.");
|
|
243
|
+
} else {
|
|
244
|
+
lines.push("Reply in plain language, or answer each question separately with numbered lines:", "1: your answer", "2: your answer", "", "Option numbers/labels work too. For multi-select questions, separate multiple answers with commas.");
|
|
245
|
+
}
|
|
246
|
+
return lines.join(`
|
|
247
|
+
`);
|
|
248
|
+
}
|
|
249
|
+
function formatGenericToolApprovalPrompt(event) {
|
|
250
|
+
const inputSummary = summarizeControlRequestInput(event.input);
|
|
251
|
+
const lines = [`The agent wants approval to run \`${event.toolName}\`.`];
|
|
252
|
+
if (inputSummary) {
|
|
253
|
+
lines.push("", "Tool input:", inputSummary);
|
|
254
|
+
}
|
|
255
|
+
lines.push("", "Reply `approve` to allow it.", "Reply with feedback instead if you want to deny it.");
|
|
256
|
+
return lines.join(`
|
|
257
|
+
`);
|
|
258
|
+
}
|
|
259
|
+
function formatChannelControlRequestPrompt(event) {
|
|
260
|
+
switch (event.kind) {
|
|
261
|
+
case "ask_user_question":
|
|
262
|
+
return formatAskUserQuestionPrompt(event);
|
|
263
|
+
case "generic_tool_approval":
|
|
264
|
+
return formatGenericToolApprovalPrompt(event);
|
|
265
|
+
default: {
|
|
266
|
+
const exhaustiveCheck = event.kind;
|
|
267
|
+
return exhaustiveCheck;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
function parseAskUserQuestionResponse(event, rawText) {
|
|
272
|
+
const input = getAskUserQuestionInput(event.input);
|
|
273
|
+
const questions = (input.questions ?? []).filter((question) => normalizeWhitespace(question.question ?? ""));
|
|
274
|
+
if (questions.length === 0) {
|
|
275
|
+
return {
|
|
276
|
+
type: "reprompt",
|
|
277
|
+
message: "I couldn't find the original question payload. Please ask the agent to try again."
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
if (questions.length === 1) {
|
|
281
|
+
const [question] = questions;
|
|
282
|
+
if (!question?.question) {
|
|
283
|
+
return {
|
|
284
|
+
type: "reprompt",
|
|
285
|
+
message: "I couldn't find the original question text. Please ask the agent to try again."
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
const answer = matchQuestionAnswer(question, rawText);
|
|
289
|
+
return {
|
|
290
|
+
type: "response",
|
|
291
|
+
response: buildAllowResponse(event.requestId, {
|
|
292
|
+
behavior: "allow",
|
|
293
|
+
updated_input: {
|
|
294
|
+
...event.input,
|
|
295
|
+
answers: {
|
|
296
|
+
...input.answers ?? {},
|
|
297
|
+
[question.question]: answer
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
})
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
const numberedAnswers = parseNumberedAnswers(rawText, questions);
|
|
304
|
+
const answers = numberedAnswers ?? parseFlexibleMultiQuestionAnswers(rawText, questions);
|
|
305
|
+
if (!answers) {
|
|
306
|
+
return {
|
|
307
|
+
type: "reprompt",
|
|
308
|
+
message: "Please send a reply so I can pass it back to the agent. You can answer naturally or use numbered lines like `1: ...` and `2: ...`."
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
return {
|
|
312
|
+
type: "response",
|
|
313
|
+
response: buildAllowResponse(event.requestId, {
|
|
314
|
+
behavior: "allow",
|
|
315
|
+
updated_input: {
|
|
316
|
+
...event.input,
|
|
317
|
+
answers: {
|
|
318
|
+
...input.answers ?? {},
|
|
319
|
+
...answers
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
})
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
function parseGenericToolApprovalResponse(event, rawText) {
|
|
326
|
+
if (isAffirmativeResponse(rawText)) {
|
|
327
|
+
const message = stripApprovalPrefix(rawText);
|
|
328
|
+
return {
|
|
329
|
+
type: "response",
|
|
330
|
+
response: buildAllowResponse(event.requestId, {
|
|
331
|
+
behavior: "allow",
|
|
332
|
+
...message ? { message } : {}
|
|
333
|
+
})
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
const feedback = stripApprovalPrefix(rawText);
|
|
337
|
+
return {
|
|
338
|
+
type: "response",
|
|
339
|
+
response: buildDenyResponse(event.requestId, feedback || "Denied by channel user.")
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
function parseChannelControlRequestResponse(event, rawText) {
|
|
343
|
+
const trimmed = rawText.trim();
|
|
344
|
+
if (!trimmed) {
|
|
345
|
+
return {
|
|
346
|
+
type: "reprompt",
|
|
347
|
+
message: formatChannelControlRequestPrompt(event)
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
switch (event.kind) {
|
|
351
|
+
case "ask_user_question":
|
|
352
|
+
return parseAskUserQuestionResponse(event, trimmed);
|
|
353
|
+
case "generic_tool_approval":
|
|
354
|
+
return parseGenericToolApprovalResponse(event, trimmed);
|
|
355
|
+
default: {
|
|
356
|
+
const exhaustiveCheck = event.kind;
|
|
357
|
+
return exhaustiveCheck;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// src/channels/control-request-coordinator.ts
|
|
363
|
+
function getChannelControlRequestScopeKey(params) {
|
|
364
|
+
return [
|
|
365
|
+
params.channel,
|
|
366
|
+
params.accountId ?? "default",
|
|
367
|
+
params.chatId,
|
|
368
|
+
params.threadId ?? ""
|
|
369
|
+
].join(":");
|
|
370
|
+
}
|
|
371
|
+
function cloneEvent(event) {
|
|
372
|
+
return structuredClone(event);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
class ChannelControlRequestCoordinator {
|
|
376
|
+
options;
|
|
377
|
+
pendingById = new Map;
|
|
378
|
+
requestIdByScope = new Map;
|
|
379
|
+
constructor(options) {
|
|
380
|
+
this.options = options;
|
|
381
|
+
}
|
|
382
|
+
restore(events) {
|
|
383
|
+
for (const event of events) {
|
|
384
|
+
this.remember(event, false);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
has(requestId) {
|
|
388
|
+
return this.pendingById.has(requestId);
|
|
389
|
+
}
|
|
390
|
+
getAll() {
|
|
391
|
+
return Array.from(this.pendingById.values()).map((pending) => ({
|
|
392
|
+
event: cloneEvent(pending.event),
|
|
393
|
+
deliveredThisProcess: pending.deliveredThisProcess
|
|
394
|
+
}));
|
|
395
|
+
}
|
|
396
|
+
async register(event) {
|
|
397
|
+
const scopeKey = getChannelControlRequestScopeKey(event.source);
|
|
398
|
+
const existingRequestId = this.requestIdByScope.get(scopeKey);
|
|
399
|
+
if (existingRequestId && existingRequestId !== event.requestId) {
|
|
400
|
+
await this.clear(existingRequestId);
|
|
401
|
+
}
|
|
402
|
+
this.remember(event, false);
|
|
403
|
+
await this.options.persist(cloneEvent(event));
|
|
404
|
+
await this.deliver(event.requestId);
|
|
405
|
+
}
|
|
406
|
+
async redeliver(requestId) {
|
|
407
|
+
return this.deliver(requestId);
|
|
408
|
+
}
|
|
409
|
+
async handleNativeResponse(input) {
|
|
410
|
+
const pending = this.pendingById.get(input.requestId);
|
|
411
|
+
if (!pending)
|
|
412
|
+
return "expired";
|
|
413
|
+
const source = pending.event.source;
|
|
414
|
+
if (source.channel !== input.channel || (source.accountId ?? "default") !== (input.accountId ?? "default") || source.chatId !== input.chatId || (source.threadId ?? null) !== (input.threadId ?? null) || source.senderId && source.senderId !== input.senderId) {
|
|
415
|
+
return "forbidden";
|
|
416
|
+
}
|
|
417
|
+
const result = await this.options.deliverResponse(cloneEvent(pending.event), input.response);
|
|
418
|
+
if (result === "handled" || result === "expired") {
|
|
419
|
+
await this.clear(input.requestId);
|
|
420
|
+
}
|
|
421
|
+
return result;
|
|
422
|
+
}
|
|
423
|
+
async tryHandleInbound(input) {
|
|
424
|
+
if (input.bypass)
|
|
425
|
+
return false;
|
|
426
|
+
const requestId = this.requestIdByScope.get(getChannelControlRequestScopeKey(input));
|
|
427
|
+
if (!requestId)
|
|
428
|
+
return false;
|
|
429
|
+
const pending = this.pendingById.get(requestId);
|
|
430
|
+
if (!pending) {
|
|
431
|
+
this.requestIdByScope.delete(getChannelControlRequestScopeKey(input));
|
|
432
|
+
return false;
|
|
433
|
+
}
|
|
434
|
+
if (pending.event.source.senderId && pending.event.source.senderId !== input.senderId) {
|
|
435
|
+
return false;
|
|
436
|
+
}
|
|
437
|
+
if (input.channel === "slack" && pending.event.kind === "generic_tool_approval") {
|
|
438
|
+
return false;
|
|
439
|
+
}
|
|
440
|
+
const parsed = parseChannelControlRequestResponse(pending.event, input.text);
|
|
441
|
+
if (parsed.type === "reprompt") {
|
|
442
|
+
await this.options.deliverReprompt(cloneEvent(pending.event), input, parsed.message);
|
|
443
|
+
return true;
|
|
444
|
+
}
|
|
445
|
+
const result = await this.options.deliverResponse(cloneEvent(pending.event), parsed.response);
|
|
446
|
+
if (result === "unavailable") {
|
|
447
|
+
await this.options.deliverReprompt(cloneEvent(pending.event), input, "I’m reconnecting to Letta Code right now, so I couldn’t use that reply yet. Please send it again in a moment.");
|
|
448
|
+
return true;
|
|
449
|
+
}
|
|
450
|
+
await this.clear(requestId);
|
|
451
|
+
if (result === "expired") {
|
|
452
|
+
await this.options.deliverReprompt(cloneEvent(pending.event), input, "That approval prompt expired before I could use your reply. Please ask the agent to try again.");
|
|
453
|
+
}
|
|
454
|
+
return true;
|
|
455
|
+
}
|
|
456
|
+
async clear(requestId) {
|
|
457
|
+
const pending = this.pendingById.get(requestId);
|
|
458
|
+
if (pending) {
|
|
459
|
+
this.pendingById.delete(requestId);
|
|
460
|
+
const scopeKey = getChannelControlRequestScopeKey(pending.event.source);
|
|
461
|
+
if (this.requestIdByScope.get(scopeKey) === requestId) {
|
|
462
|
+
this.requestIdByScope.delete(scopeKey);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
await this.options.remove(requestId);
|
|
466
|
+
}
|
|
467
|
+
clearAll() {
|
|
468
|
+
this.pendingById.clear();
|
|
469
|
+
this.requestIdByScope.clear();
|
|
470
|
+
}
|
|
471
|
+
remember(event, deliveredThisProcess) {
|
|
472
|
+
const nextEvent = cloneEvent(event);
|
|
473
|
+
this.pendingById.set(event.requestId, {
|
|
474
|
+
event: nextEvent,
|
|
475
|
+
deliveredThisProcess
|
|
476
|
+
});
|
|
477
|
+
this.requestIdByScope.set(getChannelControlRequestScopeKey(event.source), event.requestId);
|
|
478
|
+
}
|
|
479
|
+
async deliver(requestId) {
|
|
480
|
+
const pending = this.pendingById.get(requestId);
|
|
481
|
+
if (!pending)
|
|
482
|
+
return false;
|
|
483
|
+
await this.options.deliverPrompt(cloneEvent(pending.event));
|
|
484
|
+
pending.deliveredThisProcess = true;
|
|
485
|
+
return true;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
1
488
|
// src/tools/interactive-policy.ts
|
|
2
489
|
var INTERACTIVE_APPROVAL_TOOLS = new Set(["AskUserQuestion"]);
|
|
3
490
|
var INTERACTIVE_USER_INPUT_TOOL_NAMES = [
|
|
@@ -846,7 +1333,7 @@ var MAX_ACCEPTED_CLIENT_MESSAGE_IDS = 2048;
|
|
|
846
1333
|
function runtimeKey(runtime) {
|
|
847
1334
|
return `${runtime.agent_id}:${runtime.conversation_id}`;
|
|
848
1335
|
}
|
|
849
|
-
function
|
|
1336
|
+
function sourceRouteKey(source) {
|
|
850
1337
|
return [
|
|
851
1338
|
source.channel,
|
|
852
1339
|
source.accountId ?? "",
|
|
@@ -854,12 +1341,26 @@ function sourceKey(source) {
|
|
|
854
1341
|
source.threadId ?? ""
|
|
855
1342
|
].join(":");
|
|
856
1343
|
}
|
|
857
|
-
function
|
|
1344
|
+
function sourceLifecycleKey(source) {
|
|
1345
|
+
return [
|
|
1346
|
+
sourceRouteKey(source),
|
|
1347
|
+
source.messageId ?? "",
|
|
1348
|
+
source.agentId,
|
|
1349
|
+
source.conversationId
|
|
1350
|
+
].join(":");
|
|
1351
|
+
}
|
|
1352
|
+
function uniqueSourcesBy(sources, getKey) {
|
|
858
1353
|
const byKey = new Map;
|
|
859
1354
|
for (const source of sources)
|
|
860
|
-
byKey.set(
|
|
1355
|
+
byKey.set(getKey(source), source);
|
|
861
1356
|
return [...byKey.values()];
|
|
862
1357
|
}
|
|
1358
|
+
function uniqueRoutedSources(sources) {
|
|
1359
|
+
return uniqueSourcesBy(sources, sourceRouteKey);
|
|
1360
|
+
}
|
|
1361
|
+
function uniqueLifecycleSources(sources) {
|
|
1362
|
+
return uniqueSourcesBy(sources, sourceLifecycleKey);
|
|
1363
|
+
}
|
|
863
1364
|
function channelTagsForSources(sources) {
|
|
864
1365
|
return [...new Set(sources.map((source) => `channel:${source.channel}`))];
|
|
865
1366
|
}
|
|
@@ -892,7 +1393,7 @@ class ChannelGateway {
|
|
|
892
1393
|
this.disposers.push(client.onMessage((message) => this.handleMessage(message)), client.onExternalToolCall((request) => {
|
|
893
1394
|
const state = request.runtime ? this.states.get(runtimeKey(request.runtime)) : undefined;
|
|
894
1395
|
const active = state?.active;
|
|
895
|
-
const sources = active?.
|
|
1396
|
+
const sources = active?.routingSources ?? state?.routedSources ?? [];
|
|
896
1397
|
return hooks.executeExternalTool(request, sources, active?.idempotencyScope ?? null);
|
|
897
1398
|
}));
|
|
898
1399
|
}
|
|
@@ -919,12 +1420,12 @@ class ChannelGateway {
|
|
|
919
1420
|
return true;
|
|
920
1421
|
}
|
|
921
1422
|
state.pendingSourcesByClientMessageId.set(delivery.clientMessageId, {
|
|
922
|
-
sources:
|
|
1423
|
+
sources: uniqueLifecycleSources(delivery.sources),
|
|
923
1424
|
disposition: "submitting"
|
|
924
1425
|
});
|
|
925
1426
|
try {
|
|
926
1427
|
await this.enqueueRegistration(async () => {
|
|
927
|
-
state.routedSources =
|
|
1428
|
+
state.routedSources = uniqueRoutedSources([
|
|
928
1429
|
...state.routedSources,
|
|
929
1430
|
...delivery.sources
|
|
930
1431
|
]);
|
|
@@ -962,8 +1463,8 @@ class ChannelGateway {
|
|
|
962
1463
|
const pending = state.pendingSourcesByClientMessageId.get(delivery.clientMessageId);
|
|
963
1464
|
if (pending) {
|
|
964
1465
|
pending.disposition = "queued";
|
|
965
|
-
pending.acceptedAtQueueRevision = state.queueRevision;
|
|
966
1466
|
}
|
|
1467
|
+
this.reconcileExplicitQueueRemovals(state);
|
|
967
1468
|
}
|
|
968
1469
|
await Promise.all(queuedEvents);
|
|
969
1470
|
return true;
|
|
@@ -979,7 +1480,8 @@ class ChannelGateway {
|
|
|
979
1480
|
if (!state.active) {
|
|
980
1481
|
recoveredTurn = {
|
|
981
1482
|
batchId: `channel-recovered-${crypto.randomUUID()}`,
|
|
982
|
-
|
|
1483
|
+
routingSources: uniqueRoutedSources(sources),
|
|
1484
|
+
lifecycleSources: uniqueLifecycleSources(sources),
|
|
983
1485
|
progress: createChannelTurnProgressBuilder(),
|
|
984
1486
|
richDraft: null,
|
|
985
1487
|
idempotencyScope: createMessageChannelIdempotencyScope()
|
|
@@ -1020,7 +1522,7 @@ class ChannelGateway {
|
|
|
1020
1522
|
return result.accepted;
|
|
1021
1523
|
}
|
|
1022
1524
|
setRoutedSources(runtime, sources) {
|
|
1023
|
-
this.getState(runtime).routedSources =
|
|
1525
|
+
this.getState(runtime).routedSources = uniqueRoutedSources(sources);
|
|
1024
1526
|
}
|
|
1025
1527
|
getKnownRuntimes() {
|
|
1026
1528
|
return [...this.states.values()].map((state) => state.runtime);
|
|
@@ -1057,7 +1559,6 @@ class ChannelGateway {
|
|
|
1057
1559
|
state = {
|
|
1058
1560
|
runtime,
|
|
1059
1561
|
pendingSourcesByClientMessageId: new Map,
|
|
1060
|
-
queueRevision: 0,
|
|
1061
1562
|
active: null,
|
|
1062
1563
|
registrationSignature: null,
|
|
1063
1564
|
registration: null,
|
|
@@ -1174,43 +1675,81 @@ class ChannelGateway {
|
|
|
1174
1675
|
}
|
|
1175
1676
|
handleQueueUpdate(message) {
|
|
1176
1677
|
const state = this.getState(message.runtime);
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1678
|
+
for (const transition of message.removed) {
|
|
1679
|
+
const pending = state.pendingSourcesByClientMessageId.get(transition.client_message_id);
|
|
1680
|
+
if (pending) {
|
|
1681
|
+
pending.removalDisposition = transition.disposition;
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
this.reconcileExplicitQueueRemovals(state);
|
|
1685
|
+
}
|
|
1686
|
+
reconcileExplicitQueueRemovals(state) {
|
|
1687
|
+
const dequeued = [];
|
|
1688
|
+
const cancelled = [];
|
|
1180
1689
|
for (const [
|
|
1181
1690
|
clientMessageId,
|
|
1182
1691
|
pending
|
|
1183
1692
|
] of state.pendingSourcesByClientMessageId) {
|
|
1184
|
-
if (pending.disposition
|
|
1185
|
-
|
|
1186
|
-
state.pendingSourcesByClientMessageId.delete(clientMessageId);
|
|
1187
|
-
}
|
|
1188
|
-
}
|
|
1189
|
-
if (!state.active && removed.length > 0) {
|
|
1190
|
-
const first = removed[0];
|
|
1191
|
-
if (first) {
|
|
1192
|
-
this.activateSources(state, first.clientMessageId, removed.flatMap((entry) => entry.sources));
|
|
1693
|
+
if (pending.disposition !== "queued" || !pending.removalDisposition) {
|
|
1694
|
+
continue;
|
|
1193
1695
|
}
|
|
1696
|
+
const target = pending.removalDisposition === "dequeued" ? dequeued : cancelled;
|
|
1697
|
+
target.push({ clientMessageId, sources: pending.sources });
|
|
1698
|
+
state.pendingSourcesByClientMessageId.delete(clientMessageId);
|
|
1699
|
+
}
|
|
1700
|
+
const firstDequeued = dequeued[0];
|
|
1701
|
+
if (firstDequeued) {
|
|
1702
|
+
this.activateSources(state, firstDequeued.clientMessageId, dequeued.flatMap((entry) => entry.sources));
|
|
1703
|
+
}
|
|
1704
|
+
for (const entry of cancelled) {
|
|
1705
|
+
this.enqueueHook(state, () => this.hooks.onLifecycle({
|
|
1706
|
+
type: "finished",
|
|
1707
|
+
batchId: `channel-${entry.clientMessageId}`,
|
|
1708
|
+
sources: entry.sources,
|
|
1709
|
+
outcome: "cancelled",
|
|
1710
|
+
stopReason: "cancelled"
|
|
1711
|
+
}));
|
|
1194
1712
|
}
|
|
1195
1713
|
}
|
|
1196
1714
|
activateSources(state, clientMessageId, sources) {
|
|
1197
1715
|
if (state.active) {
|
|
1716
|
+
const knownLifecycleKeys = new Set(state.active.lifecycleSources.map(sourceLifecycleKey));
|
|
1717
|
+
const addedLifecycleSources = uniqueLifecycleSources(sources).filter((source) => !knownLifecycleKeys.has(sourceLifecycleKey(source)));
|
|
1718
|
+
if (addedLifecycleSources.length === 0)
|
|
1719
|
+
return;
|
|
1720
|
+
state.active.lifecycleSources = uniqueLifecycleSources([
|
|
1721
|
+
...state.active.lifecycleSources,
|
|
1722
|
+
...addedLifecycleSources
|
|
1723
|
+
]);
|
|
1724
|
+
state.active.routingSources = uniqueRoutedSources([
|
|
1725
|
+
...state.active.routingSources,
|
|
1726
|
+
...sources
|
|
1727
|
+
]);
|
|
1728
|
+
const processingEvent2 = {
|
|
1729
|
+
type: "processing",
|
|
1730
|
+
batchId: state.active.batchId,
|
|
1731
|
+
sources: addedLifecycleSources
|
|
1732
|
+
};
|
|
1733
|
+
this.enqueueHook(state, () => this.hooks.onLifecycle(processingEvent2));
|
|
1198
1734
|
return;
|
|
1199
1735
|
}
|
|
1736
|
+
const routingSources = uniqueRoutedSources(sources);
|
|
1737
|
+
const lifecycleSources = uniqueLifecycleSources(sources);
|
|
1200
1738
|
state.active = {
|
|
1201
1739
|
batchId: `channel-${clientMessageId}`,
|
|
1202
|
-
|
|
1740
|
+
routingSources,
|
|
1741
|
+
lifecycleSources,
|
|
1203
1742
|
progress: createChannelTurnProgressBuilder(),
|
|
1204
1743
|
richDraft: this.hooks.createRichDraft?.({
|
|
1205
1744
|
batchId: `channel-${clientMessageId}`,
|
|
1206
|
-
sources
|
|
1745
|
+
sources: routingSources
|
|
1207
1746
|
}) ?? null,
|
|
1208
1747
|
idempotencyScope: createMessageChannelIdempotencyScope()
|
|
1209
1748
|
};
|
|
1210
1749
|
const processingEvent = {
|
|
1211
1750
|
type: "processing",
|
|
1212
1751
|
batchId: state.active.batchId,
|
|
1213
|
-
sources: state.active.
|
|
1752
|
+
sources: state.active.lifecycleSources
|
|
1214
1753
|
};
|
|
1215
1754
|
this.enqueueHook(state, () => this.hooks.onLifecycle(processingEvent));
|
|
1216
1755
|
}
|
|
@@ -1228,7 +1767,7 @@ class ChannelGateway {
|
|
|
1228
1767
|
this.enqueueHook(state, () => this.hooks.onProgress({
|
|
1229
1768
|
type: "progress",
|
|
1230
1769
|
batchId: active.batchId,
|
|
1231
|
-
sources: active.
|
|
1770
|
+
sources: active.routingSources,
|
|
1232
1771
|
...update
|
|
1233
1772
|
}));
|
|
1234
1773
|
}
|
|
@@ -1248,7 +1787,7 @@ class ChannelGateway {
|
|
|
1248
1787
|
this.enqueueHook(state, () => this.hooks.onLifecycle({
|
|
1249
1788
|
type: "finished",
|
|
1250
1789
|
batchId: active.batchId,
|
|
1251
|
-
sources: active.
|
|
1790
|
+
sources: active.lifecycleSources,
|
|
1252
1791
|
outcome: lifecycleOutcome(terminal.stopReason),
|
|
1253
1792
|
stopReason: terminal.stopReason,
|
|
1254
1793
|
...terminal.runId ?? active.runId ? { runId: terminal.runId ?? active.runId } : {},
|
|
@@ -1264,9 +1803,9 @@ class ChannelGateway {
|
|
|
1264
1803
|
}));
|
|
1265
1804
|
if (!state)
|
|
1266
1805
|
return;
|
|
1267
|
-
const sources = state.active?.
|
|
1806
|
+
const sources = state.active?.routingSources ?? [];
|
|
1268
1807
|
state.replayedControlRequestIds.add(message.request_id);
|
|
1269
|
-
const sourceScopes = new Map(sources.map((source2) => [
|
|
1808
|
+
const sourceScopes = new Map(sources.map((source2) => [sourceRouteKey(source2), source2]));
|
|
1270
1809
|
if (sourceScopes.size !== 1)
|
|
1271
1810
|
return;
|
|
1272
1811
|
const source = [...sourceScopes.values()][0];
|
|
@@ -2312,10 +2851,12 @@ function buildMessageChannelExternalToolDefinition(options) {
|
|
|
2312
2851
|
};
|
|
2313
2852
|
}
|
|
2314
2853
|
export {
|
|
2854
|
+
formatChannelControlRequestPrompt,
|
|
2315
2855
|
executeMessageChannelExternalTool,
|
|
2316
2856
|
executeMessageChannel,
|
|
2317
2857
|
buildMessageChannelExternalToolDefinition,
|
|
2318
|
-
ChannelGateway
|
|
2858
|
+
ChannelGateway,
|
|
2859
|
+
ChannelControlRequestCoordinator
|
|
2319
2860
|
};
|
|
2320
2861
|
|
|
2321
|
-
//# debugId=
|
|
2862
|
+
//# debugId=8EA0293E1912A46464756E2164756E21
|