@segmentstream/agent 0.2.0 → 0.2.1
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/extension/lib/telegram/adapter.mjs +14 -5
- package/dist/extension/lib/telegram/inbound.d.ts +2 -1
- package/dist/extension/lib/telegram/inbound.mjs +25 -10
- package/dist/extension/lib/telegram/observed.d.ts +29 -3
- package/dist/extension/lib/telegram/observed.mjs +48 -14
- package/dist/extension/lib/telegram/router.d.ts +11 -3
- package/dist/extension/lib/telegram/router.mjs +89 -13
- package/dist/extension/lib/telegram/routing-context.d.ts +13 -0
- package/dist/extension/lib/telegram/routing-context.mjs +40 -0
- package/dist/extension/lib/telegram/sessions.d.ts +5 -0
- package/dist/extension/lib/telegram/silent.d.ts +2 -0
- package/dist/extension/lib/telegram/silent.mjs +30 -5
- package/dist/extension/lib/telegram/turn-errors.d.ts +9 -0
- package/dist/extension/lib/telegram/turn-errors.mjs +20 -0
- package/package.json +1 -1
|
@@ -14,13 +14,14 @@ import { botIdentityCache } from "./bot.mjs";
|
|
|
14
14
|
import { agentChats } from "./chats.mjs";
|
|
15
15
|
import { observedMessages } from "./observed.mjs";
|
|
16
16
|
import { decisionRouter } from "./router.mjs";
|
|
17
|
+
import { replyDelivery } from "./silent.mjs";
|
|
17
18
|
import { scopedAddress, sessionPolicy } from "./sessions.mjs";
|
|
18
19
|
import { downloadTelegramFile, gatewayTranscriber } from "./voice.mjs";
|
|
19
20
|
import { handleInboundMessage } from "./inbound.mjs";
|
|
20
21
|
import { InboundRequest, currentInboundRequest, runInboundRequest, withReplacedText } from "./inbound-request.mjs";
|
|
21
22
|
import { TELEGRAM_PLACEHOLDER_TOKEN, TELEGRAM_PROXY_ORIGIN, telegramProxyFetch } from "./proxy-fetch.mjs";
|
|
22
23
|
import { messagesRoute } from "./requests.mjs";
|
|
23
|
-
import {
|
|
24
|
+
import { withTurnErrorLogging } from "./turn-errors.mjs";
|
|
24
25
|
import { telegramWebhookVerifier } from "./webhook.mjs";
|
|
25
26
|
import { telegramChannel } from "eve/channels/telegram";
|
|
26
27
|
const eveHistory = async (sessionId) => {
|
|
@@ -31,7 +32,11 @@ const eveHistory = async (sessionId) => {
|
|
|
31
32
|
follow: false
|
|
32
33
|
})) events.push({
|
|
33
34
|
type: event.type,
|
|
34
|
-
meta: { at: event.meta.at }
|
|
35
|
+
meta: { at: event.meta.at },
|
|
36
|
+
...event.type === "message.completed" ? { data: {
|
|
37
|
+
message: event.data.message,
|
|
38
|
+
finishReason: event.data.finishReason
|
|
39
|
+
} } : {}
|
|
35
40
|
});
|
|
36
41
|
return events;
|
|
37
42
|
};
|
|
@@ -39,9 +44,10 @@ function segmentstreamTelegram(options) {
|
|
|
39
44
|
const environment = options.environment ?? process.env;
|
|
40
45
|
const token = options.token ?? runtimeToken;
|
|
41
46
|
const observed = options.observed ?? observedMessages();
|
|
47
|
+
const readHistory = options.history ?? eveHistory;
|
|
42
48
|
const policy = sessionPolicy({
|
|
43
49
|
settings: options.sessions,
|
|
44
|
-
read:
|
|
50
|
+
read: readHistory
|
|
45
51
|
});
|
|
46
52
|
const bot = botIdentityCache();
|
|
47
53
|
const proxies = new Map();
|
|
@@ -117,14 +123,16 @@ function segmentstreamTelegram(options) {
|
|
|
117
123
|
}),
|
|
118
124
|
attachments: attachments(),
|
|
119
125
|
observed,
|
|
126
|
+
readHistory,
|
|
120
127
|
chats,
|
|
121
128
|
connectUrl: (code) => `${appOrigin(environment)}/agent/connect?code=${encodeURIComponent(code)}`
|
|
122
129
|
}
|
|
123
130
|
}),
|
|
124
|
-
events: { "message.completed":
|
|
131
|
+
events: { "message.completed": replyDelivery(observed) }
|
|
125
132
|
});
|
|
126
133
|
const adapter = channel.adapter;
|
|
127
134
|
const fetchTelegramFile = adapter?.fetchFile;
|
|
135
|
+
const turnFailed = adapter?.["turn.failed"];
|
|
128
136
|
async function fetchFile(url, context) {
|
|
129
137
|
const reference = parseAttachmentUrl(url);
|
|
130
138
|
if (reference === void 0) return fetchTelegramFile === void 0 ? null : fetchTelegramFile(url, context);
|
|
@@ -144,7 +152,8 @@ function segmentstreamTelegram(options) {
|
|
|
144
152
|
...channel,
|
|
145
153
|
...adapter === void 0 ? {} : { adapter: {
|
|
146
154
|
...adapter,
|
|
147
|
-
fetchFile
|
|
155
|
+
fetchFile,
|
|
156
|
+
...turnFailed === void 0 ? {} : { "turn.failed": withTurnErrorLogging(turnFailed) }
|
|
148
157
|
} },
|
|
149
158
|
routes: [...channel.routes.map((route) => gatedRoute({
|
|
150
159
|
route,
|
|
@@ -7,7 +7,7 @@ import { type AgentChats } from "./chats.js";
|
|
|
7
7
|
import type { InboundRequest } from "./inbound-request.js";
|
|
8
8
|
import { type ObservedMessages } from "./observed.js";
|
|
9
9
|
import { type RouteDecider } from "./router.js";
|
|
10
|
-
import { type SessionPolicy } from "./sessions.js";
|
|
10
|
+
import { type HistoryReader, type SessionPolicy } from "./sessions.js";
|
|
11
11
|
import { type Transcriber } from "./voice.js";
|
|
12
12
|
/**
|
|
13
13
|
* eve's Telegram `onMessage` hook, in the order `agent-design.md` gives:
|
|
@@ -43,6 +43,7 @@ export interface InboundDependencies {
|
|
|
43
43
|
/** The attachment store; without it, files reach the turn as eve's Telegram file parts. */
|
|
44
44
|
readonly attachments?: AgentAttachments;
|
|
45
45
|
readonly observed: ObservedMessages;
|
|
46
|
+
readonly readHistory: HistoryReader;
|
|
46
47
|
readonly chats: AgentChats;
|
|
47
48
|
/** The app's `/agent/connect` link for a pairing code. */
|
|
48
49
|
readonly connectUrl: (code: string) => string;
|
|
@@ -8,7 +8,8 @@ import { telegramPrincipal } from "./principal.mjs";
|
|
|
8
8
|
import { chatContext } from "./chats.mjs";
|
|
9
9
|
import { attachmentsContext, stageFiles } from "./files.mjs";
|
|
10
10
|
import { observedChatKey, observedContext } from "./observed.mjs";
|
|
11
|
-
import {
|
|
11
|
+
import { addressingReason, routeMessage } from "./router.mjs";
|
|
12
|
+
import { routingContext } from "./routing-context.mjs";
|
|
12
13
|
import { messageAddress, scopedAddress } from "./sessions.mjs";
|
|
13
14
|
import { voiceNote, voiceOf, voiceText } from "./voice.mjs";
|
|
14
15
|
const UNKNOWN_PRIVATE = "Hi, I don't know you yet. Connect your Telegram account to the workspace and I'll act as you. The button works for ten minutes.";
|
|
@@ -39,6 +40,8 @@ function authorOf(user, member) {
|
|
|
39
40
|
function observedMessage(message, text, member) {
|
|
40
41
|
return {
|
|
41
42
|
author: authorOf(message.from, member),
|
|
43
|
+
authorId: message.from.id,
|
|
44
|
+
...message.from.username === void 0 ? {} : { username: message.from.username },
|
|
42
45
|
text,
|
|
43
46
|
at: sentAt(message)
|
|
44
47
|
};
|
|
@@ -159,7 +162,7 @@ async function handleInboundMessage(input) {
|
|
|
159
162
|
from: message.from
|
|
160
163
|
};
|
|
161
164
|
const bot = await dependencies.bot(context.telegram);
|
|
162
|
-
const addressed =
|
|
165
|
+
const addressed = addressingReason(sent, bot);
|
|
163
166
|
const lookup = await dependencies.identities.lookup({
|
|
164
167
|
provider: "telegram",
|
|
165
168
|
externalId: sent.from.id
|
|
@@ -172,7 +175,7 @@ async function handleInboundMessage(input) {
|
|
|
172
175
|
}) : void 0;
|
|
173
176
|
if (report !== void 0) input.request?.waitUntil?.(report);
|
|
174
177
|
if (lookup.status !== "paired") {
|
|
175
|
-
if (addressed) await answerUnpaired({
|
|
178
|
+
if (addressed !== void 0) await answerUnpaired({
|
|
176
179
|
context,
|
|
177
180
|
message: sent,
|
|
178
181
|
lookup,
|
|
@@ -187,7 +190,7 @@ async function handleInboundMessage(input) {
|
|
|
187
190
|
const caption = sent.text || sent.caption;
|
|
188
191
|
const notes = [];
|
|
189
192
|
let text = caption;
|
|
190
|
-
if (voice !== void 0 && (addressed || dependencies.policy === "smart")) {
|
|
193
|
+
if (voice !== void 0 && (addressed !== void 0 || dependencies.policy === "smart")) {
|
|
191
194
|
const spoken = await voiceText({
|
|
192
195
|
voice,
|
|
193
196
|
model: dependencies.sttModel,
|
|
@@ -203,14 +206,25 @@ async function handleInboundMessage(input) {
|
|
|
203
206
|
...sent.messageThreadId === void 0 ? {} : { messageThreadId: sent.messageThreadId }
|
|
204
207
|
});
|
|
205
208
|
if (await routeMessage({
|
|
209
|
+
chatId: sent.chat.id,
|
|
210
|
+
memberId: lookup.member.id,
|
|
206
211
|
policy: dependencies.policy,
|
|
207
212
|
addressed,
|
|
208
213
|
decide: dependencies.decide,
|
|
209
|
-
question: async () =>
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
+
question: async () => {
|
|
215
|
+
const recent = await routingContext({
|
|
216
|
+
address,
|
|
217
|
+
operations,
|
|
218
|
+
read: dependencies.readHistory,
|
|
219
|
+
remembered: dependencies.observed.recent(chat),
|
|
220
|
+
at: sentAt(sent)
|
|
221
|
+
});
|
|
222
|
+
return {
|
|
223
|
+
agent: await input.request?.agentDescription() ?? "",
|
|
224
|
+
recent,
|
|
225
|
+
message: observedMessage(sent, text, lookup.member)
|
|
226
|
+
};
|
|
227
|
+
}
|
|
214
228
|
}) === "observe") {
|
|
215
229
|
dependencies.observed.add(chat, observedMessage(sent, text === "" ? "[attachment]" : text, lookup.member));
|
|
216
230
|
return null;
|
|
@@ -223,7 +237,7 @@ async function handleInboundMessage(input) {
|
|
|
223
237
|
address,
|
|
224
238
|
operations,
|
|
225
239
|
...newest === void 0 ? {} : { activeAt: Date.parse(newest.at) || 0 }
|
|
226
|
-
}) === "cleared") dependencies.observed.
|
|
240
|
+
}) === "cleared") dependencies.observed.clear(chat);
|
|
227
241
|
dependencies.sessionPolicy.delivered(address);
|
|
228
242
|
}
|
|
229
243
|
await context.telegram.startTyping();
|
|
@@ -235,6 +249,7 @@ async function handleInboundMessage(input) {
|
|
|
235
249
|
});
|
|
236
250
|
if (input.request !== void 0 && files.size > 0) input.request.files = files;
|
|
237
251
|
await report;
|
|
252
|
+
dependencies.observed.remember(chat, observedMessage(sent, text, lookup.member));
|
|
238
253
|
return {
|
|
239
254
|
auth: telegramPrincipal({
|
|
240
255
|
message: sent,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Bounded group conversation context for routing, and the backlog observed without answering.
|
|
3
|
+
* A reply drains only the backlog: recent member messages and the agent's own replies remain
|
|
4
|
+
* available to decide whether a later unaddressed message answers one of the agent's questions.
|
|
5
5
|
*
|
|
6
6
|
* eve's `defineState` would be the durable home, but it is session state and needs an active eve
|
|
7
7
|
* context; an observed message has neither, because observing must not start a session. The ring
|
|
@@ -15,14 +15,40 @@ export interface ObservedMessage {
|
|
|
15
15
|
readonly text: string;
|
|
16
16
|
/** ISO time the message was sent. */
|
|
17
17
|
readonly at: string;
|
|
18
|
+
/** Stable Telegram identity, independent of changes to the member's display name or roles. */
|
|
19
|
+
readonly authorId?: string;
|
|
20
|
+
readonly username?: string;
|
|
18
21
|
}
|
|
22
|
+
export interface MessageRecipient {
|
|
23
|
+
readonly author: string;
|
|
24
|
+
readonly id?: string;
|
|
25
|
+
readonly username?: string;
|
|
26
|
+
}
|
|
27
|
+
export interface AgentMessage extends ObservedMessage {
|
|
28
|
+
readonly role: "agent";
|
|
29
|
+
/** Explicit @mentions take precedence over the member whose turn produced the reply. */
|
|
30
|
+
readonly addressedTo: ReadonlyArray<MessageRecipient>;
|
|
31
|
+
readonly inReplyTo?: MessageRecipient;
|
|
32
|
+
}
|
|
33
|
+
export type RecentMessage = ObservedMessage | AgentMessage;
|
|
19
34
|
export interface ObservedMessages {
|
|
20
35
|
add(chat: string, message: ObservedMessage): void;
|
|
36
|
+
/** Records a received message that starts a turn, or a successfully delivered agent reply. */
|
|
37
|
+
remember(chat: string, message: RecentMessage): void;
|
|
21
38
|
list(chat: string): ReadonlyArray<ObservedMessage>;
|
|
39
|
+
recent(chat: string): ReadonlyArray<RecentMessage>;
|
|
22
40
|
/** Returns and forgets the chat's ring, once a reply has carried it into a turn. */
|
|
23
41
|
take(chat: string): ReadonlyArray<ObservedMessage>;
|
|
42
|
+
/** An idle reset forgets both the backlog and the earlier conversation. */
|
|
43
|
+
clear(chat: string): void;
|
|
24
44
|
}
|
|
25
45
|
export declare function observedMessages(capacity?: number): ObservedMessages;
|
|
46
|
+
/** Recipient metadata for an agent reply; explicit mentions are visible even in proactive turns. */
|
|
47
|
+
export declare function agentMessage(input: {
|
|
48
|
+
readonly text: string;
|
|
49
|
+
readonly at: string;
|
|
50
|
+
readonly inReplyTo?: MessageRecipient;
|
|
51
|
+
}): AgentMessage;
|
|
26
52
|
/** The key of a chat's ring: the chat and, in a forum, the topic. */
|
|
27
53
|
export declare function observedChatKey(input: {
|
|
28
54
|
readonly chatId: string;
|
|
@@ -9,29 +9,63 @@ const MAXIMUM_CHATS = 500;
|
|
|
9
9
|
const MAXIMUM_TEXT_LENGTH = 1e3;
|
|
10
10
|
function observedMessages(capacity = 30) {
|
|
11
11
|
const chats = new Map();
|
|
12
|
+
function remember(chat, message, observe) {
|
|
13
|
+
const previous = chats.get(chat);
|
|
14
|
+
const bounded = {
|
|
15
|
+
...message,
|
|
16
|
+
text: message.text.slice(0, MAXIMUM_TEXT_LENGTH)
|
|
17
|
+
};
|
|
18
|
+
const backlog = previous?.observed ?? [];
|
|
19
|
+
chats.delete(chat);
|
|
20
|
+
chats.set(chat, {
|
|
21
|
+
observed: observe ? [...backlog, bounded].slice(-capacity) : backlog,
|
|
22
|
+
recent: [...previous?.recent ?? [], bounded].slice(-capacity)
|
|
23
|
+
});
|
|
24
|
+
if (chats.size > MAXIMUM_CHATS) {
|
|
25
|
+
const oldest = chats.keys().next();
|
|
26
|
+
if (oldest.done !== true) chats.delete(oldest.value);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
12
29
|
return {
|
|
13
30
|
add(chat, message) {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
chats.delete(chat);
|
|
19
|
-
chats.set(chat, ring.slice(-capacity));
|
|
20
|
-
if (chats.size > MAXIMUM_CHATS) {
|
|
21
|
-
const oldest = chats.keys().next();
|
|
22
|
-
if (oldest.done !== true) chats.delete(oldest.value);
|
|
23
|
-
}
|
|
31
|
+
remember(chat, message, true);
|
|
32
|
+
},
|
|
33
|
+
remember(chat, message) {
|
|
34
|
+
remember(chat, message, false);
|
|
24
35
|
},
|
|
25
36
|
list(chat) {
|
|
26
|
-
return chats.get(chat) ?? [];
|
|
37
|
+
return chats.get(chat)?.observed ?? [];
|
|
38
|
+
},
|
|
39
|
+
recent(chat) {
|
|
40
|
+
return chats.get(chat)?.recent ?? [];
|
|
27
41
|
},
|
|
28
42
|
take(chat) {
|
|
29
|
-
const
|
|
43
|
+
const previous = chats.get(chat);
|
|
44
|
+
if (previous !== void 0) chats.set(chat, {
|
|
45
|
+
...previous,
|
|
46
|
+
observed: []
|
|
47
|
+
});
|
|
48
|
+
return previous?.observed ?? [];
|
|
49
|
+
},
|
|
50
|
+
clear(chat) {
|
|
30
51
|
chats.delete(chat);
|
|
31
|
-
return ring;
|
|
32
52
|
}
|
|
33
53
|
};
|
|
34
54
|
}
|
|
55
|
+
function agentMessage(input) {
|
|
56
|
+
const usernames = Array.from(input.text.matchAll(/(?:^|[^A-Za-z0-9_])@([A-Za-z0-9_]+)/gu), (match) => match[1] ?? "");
|
|
57
|
+
const addressedTo = usernames.length > 0 ? [...new Set(usernames)].map((username) => ({
|
|
58
|
+
author: `@${username}`,
|
|
59
|
+
username
|
|
60
|
+
})) : input.inReplyTo === void 0 ? [] : [input.inReplyTo];
|
|
61
|
+
return {
|
|
62
|
+
...input,
|
|
63
|
+
text: input.text.slice(0, MAXIMUM_TEXT_LENGTH),
|
|
64
|
+
author: "the agent",
|
|
65
|
+
role: "agent",
|
|
66
|
+
addressedTo
|
|
67
|
+
};
|
|
68
|
+
}
|
|
35
69
|
function observedChatKey(input) {
|
|
36
70
|
return input.messageThreadId === void 0 ? input.chatId : `${input.chatId}:${input.messageThreadId}`;
|
|
37
71
|
}
|
|
@@ -44,4 +78,4 @@ function observedContext(messages) {
|
|
|
44
78
|
"</observed_messages>"
|
|
45
79
|
].join("\n")];
|
|
46
80
|
}
|
|
47
|
-
export { OBSERVED_MESSAGES, observedChatKey, observedContext, observedMessages };
|
|
81
|
+
export { OBSERVED_MESSAGES, agentMessage, observedChatKey, observedContext, observedMessages };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { TelegramMessage } from "eve/channels/telegram";
|
|
2
2
|
import type { TelegramPolicy } from "../../config.js";
|
|
3
|
-
import type { ObservedMessage } from "./observed.js";
|
|
3
|
+
import type { ObservedMessage, RecentMessage } from "./observed.js";
|
|
4
4
|
/**
|
|
5
5
|
* The inbound router (`agent-design.md`, section 4): every message from a paired member is either
|
|
6
6
|
* answered or observed before eve starts a turn. A private chat, a mention, a reply to the bot and
|
|
@@ -8,18 +8,22 @@ import type { ObservedMessage } from "./observed.js";
|
|
|
8
8
|
* policy, put to one decision on the decision model.
|
|
9
9
|
*/
|
|
10
10
|
export type Route = "observe" | "reply";
|
|
11
|
+
export type AddressingReason = "private" | "mention" | "reply-to-bot" | "command";
|
|
11
12
|
/** The bot, as Telegram's `getMe` describes it. Unknown when `getMe` failed. */
|
|
12
13
|
export interface BotIdentity {
|
|
13
14
|
readonly id: string;
|
|
14
15
|
readonly username: string | undefined;
|
|
15
16
|
}
|
|
17
|
+
/** Why the message is for the bot without asking any model. */
|
|
18
|
+
export declare function addressingReason(message: TelegramMessage, bot: BotIdentity | undefined): AddressingReason | undefined;
|
|
16
19
|
/** Whether the message is for the bot without asking any model. */
|
|
17
20
|
export declare function isAddressed(message: TelegramMessage, bot: BotIdentity | undefined): boolean;
|
|
18
21
|
/** What the decision model sees. */
|
|
19
22
|
export interface RouteQuestion {
|
|
20
23
|
/** The agent's own description, so the model knows what it can be asked for. */
|
|
21
24
|
readonly agent: string;
|
|
22
|
-
|
|
25
|
+
/** Recent received messages and delivered agent replies, oldest first. */
|
|
26
|
+
readonly recent: ReadonlyArray<RecentMessage>;
|
|
23
27
|
readonly message: ObservedMessage;
|
|
24
28
|
}
|
|
25
29
|
/** Decides whether a group message not addressed to the bot is still for it. */
|
|
@@ -51,6 +55,8 @@ export type DecideFunction = (options: {
|
|
|
51
55
|
};
|
|
52
56
|
};
|
|
53
57
|
}>;
|
|
58
|
+
/** An unthreaded answer minutes later may continue a question, but old questions are not hints. */
|
|
59
|
+
export declare const FOLLOW_UP_WINDOW_MS: number;
|
|
54
60
|
/** A {@link RouteDecider} that asks one `decide` question on the configured decision model. */
|
|
55
61
|
export declare function decisionRouter(input: {
|
|
56
62
|
readonly decide: DecideFunction;
|
|
@@ -61,8 +67,10 @@ export declare function decisionRouter(input: {
|
|
|
61
67
|
* mistake in a group chat.
|
|
62
68
|
*/
|
|
63
69
|
export declare function routeMessage(input: {
|
|
70
|
+
readonly chatId: string;
|
|
71
|
+
readonly memberId: string;
|
|
64
72
|
readonly policy: TelegramPolicy;
|
|
65
|
-
readonly addressed:
|
|
73
|
+
readonly addressed: AddressingReason | undefined;
|
|
66
74
|
/** Builds the question only when a decision is needed: it reads the agent's description. */
|
|
67
75
|
readonly question: () => Promise<RouteQuestion>;
|
|
68
76
|
readonly decide: RouteDecider;
|
|
@@ -22,43 +22,119 @@ function repliesToBot(message, bot) {
|
|
|
22
22
|
if (author === void 0) return false;
|
|
23
23
|
return bot !== void 0 && author.id === bot.id;
|
|
24
24
|
}
|
|
25
|
-
function
|
|
25
|
+
function addressingReason(message, bot) {
|
|
26
26
|
const text = message.text || message.caption;
|
|
27
|
-
|
|
27
|
+
if (message.chat.type === "private") return "private";
|
|
28
|
+
if (isCommand(text, bot)) return "command";
|
|
29
|
+
if (mentions(text, bot)) return "mention";
|
|
30
|
+
return repliesToBot(message, bot) ? "reply-to-bot" : void 0;
|
|
31
|
+
}
|
|
32
|
+
function isAddressed(message, bot) {
|
|
33
|
+
return addressingReason(message, bot) !== void 0;
|
|
28
34
|
}
|
|
29
35
|
const DECISION_TIMEOUT_MS = 15e3;
|
|
36
|
+
const FOLLOW_UP_WINDOW_MS = 18e5;
|
|
37
|
+
function isAgentMessage(message) {
|
|
38
|
+
return "role" in message && message.role === "agent";
|
|
39
|
+
}
|
|
40
|
+
function recipientLine(recipient) {
|
|
41
|
+
const identity = [...recipient.id === void 0 ? [] : [`user_id ${recipient.id}`], ...recipient.username === void 0 || recipient.author === `@${recipient.username}` ? [] : [`@${recipient.username}`]];
|
|
42
|
+
return identity.length === 0 ? recipient.author : `${recipient.author} (${identity.join(", ")})`;
|
|
43
|
+
}
|
|
30
44
|
function line(message) {
|
|
31
|
-
|
|
45
|
+
if (isAgentMessage(message)) {
|
|
46
|
+
const addressedTo = message.addressedTo.map(recipientLine).join(", ");
|
|
47
|
+
const replyingTo = message.inReplyTo === void 0 ? void 0 : recipientLine(message.inReplyTo);
|
|
48
|
+
const recipients = addressedTo === "" ? "recipient unspecified" : `addressed to ${addressedTo}`;
|
|
49
|
+
const reply = replyingTo === void 0 ? "" : `; responding to ${replyingTo}`;
|
|
50
|
+
return `[${message.at}] THE AGENT (${recipients}${reply}): ${message.text}`;
|
|
51
|
+
}
|
|
52
|
+
const author = recipientLine({
|
|
53
|
+
author: message.author,
|
|
54
|
+
...message.authorId === void 0 ? {} : { id: message.authorId },
|
|
55
|
+
...message.username === void 0 ? {} : { username: message.username }
|
|
56
|
+
});
|
|
57
|
+
return `[${message.at}] ${author}: ${message.text}`;
|
|
58
|
+
}
|
|
59
|
+
function recentlyAddressed(message, sender) {
|
|
60
|
+
return message.addressedTo.some((recipient) => recipient.id !== void 0 ? recipient.id === sender.authorId : recipient.username !== void 0 && sender.username !== void 0 && recipient.username.toLowerCase() === sender.username.toLowerCase());
|
|
32
61
|
}
|
|
33
62
|
function decisionRouter(input) {
|
|
34
63
|
return async (question) => {
|
|
64
|
+
const sentAt = Date.parse(question.message.at);
|
|
65
|
+
const recent = question.recent.filter((message) => {
|
|
66
|
+
if (!isAgentMessage(message)) return true;
|
|
67
|
+
const age = sentAt - Date.parse(message.at);
|
|
68
|
+
return age >= 0 && age <= 18e5;
|
|
69
|
+
});
|
|
70
|
+
const followUps = recent.filter((message) => isAgentMessage(message) && recentlyAddressed(message, question.message));
|
|
71
|
+
const abortSignal = AbortSignal.timeout(DECISION_TIMEOUT_MS);
|
|
35
72
|
return (await input.decide({
|
|
36
73
|
model: input.model,
|
|
37
74
|
state: {
|
|
38
75
|
agent: question.agent,
|
|
39
|
-
recent_messages:
|
|
76
|
+
recent_messages: recent.map(line),
|
|
77
|
+
possible_follow_up_to: followUps.map(line),
|
|
40
78
|
message: line(question.message)
|
|
41
79
|
},
|
|
42
80
|
questions: { route: {
|
|
43
81
|
type: "choice",
|
|
44
|
-
instructions: "A group chat message arrived that does not mention the agent. Decide whether the agent should answer it, given what the agent does and
|
|
82
|
+
instructions: "A group chat message arrived that does not mention the agent. Decide whether the agent should answer it, given what the agent does and the recent conversation. THE AGENT marks the agent's own messages; recipient metadata identifies who it addressed or replied to. possible_follow_up_to lists agent messages addressed to this sender within 30 minutes. Check whether the message answers or follows up on an open question the agent asked, including numbered questions, short confirmations, corrections, and where to find data. Such answers should receive a reply without a mention or Telegram reply. Consider explicit recipients in the text; a matching sender or recent message alone does not make unrelated chatter a follow-up.",
|
|
45
83
|
criteria: {
|
|
46
|
-
observe: "The message is people talking among themselves, or
|
|
47
|
-
reply: "The message asks the agent,
|
|
84
|
+
observe: "The message is unrelated chatter or people talking among themselves, and does not answer or follow up on an open question the agent asked.",
|
|
85
|
+
reply: "The message asks the agent, asks for something the agent does, or answers or follows up on an open question the agent asked, even without naming or replying to it."
|
|
48
86
|
}
|
|
49
87
|
} },
|
|
50
|
-
abortSignal
|
|
88
|
+
abortSignal
|
|
89
|
+
}).catch((error) => {
|
|
90
|
+
if (abortSignal.aborted) throw new RoutingTimeoutError(error);
|
|
91
|
+
throw error;
|
|
51
92
|
})).answers.route.choice;
|
|
52
93
|
};
|
|
53
94
|
}
|
|
95
|
+
var RoutingTimeoutError = class extends Error {
|
|
96
|
+
constructor(cause) {
|
|
97
|
+
super("Telegram routing decision timed out after 15 seconds.", { cause });
|
|
98
|
+
this.name = "TimeoutError";
|
|
99
|
+
}
|
|
100
|
+
};
|
|
54
101
|
async function routeMessage(input) {
|
|
55
|
-
|
|
56
|
-
|
|
102
|
+
const identity = {
|
|
103
|
+
chatId: input.chatId,
|
|
104
|
+
memberId: input.memberId
|
|
105
|
+
};
|
|
106
|
+
if (input.addressed !== void 0) {
|
|
107
|
+
console.info("[segmentstream] telegram route", {
|
|
108
|
+
...identity,
|
|
109
|
+
route: "reply",
|
|
110
|
+
reason: input.addressed
|
|
111
|
+
});
|
|
112
|
+
return "reply";
|
|
113
|
+
}
|
|
114
|
+
if (input.policy === "mention") {
|
|
115
|
+
console.info("[segmentstream] telegram route", {
|
|
116
|
+
...identity,
|
|
117
|
+
route: "observe",
|
|
118
|
+
reason: "mention-policy"
|
|
119
|
+
});
|
|
120
|
+
return "observe";
|
|
121
|
+
}
|
|
57
122
|
try {
|
|
58
|
-
|
|
123
|
+
const route = await input.decide(await input.question());
|
|
124
|
+
console.info("[segmentstream] telegram route", {
|
|
125
|
+
...identity,
|
|
126
|
+
route,
|
|
127
|
+
reason: "decision-model"
|
|
128
|
+
});
|
|
129
|
+
return route;
|
|
59
130
|
} catch (error) {
|
|
60
|
-
console.error("[segmentstream]
|
|
131
|
+
console.error("[segmentstream] telegram route", {
|
|
132
|
+
...identity,
|
|
133
|
+
route: "observe",
|
|
134
|
+
reason: error instanceof Error && error.name === "TimeoutError" ? "decision-timeout" : "decision-failure",
|
|
135
|
+
error
|
|
136
|
+
});
|
|
61
137
|
return "observe";
|
|
62
138
|
}
|
|
63
139
|
}
|
|
64
|
-
export { decisionRouter, isAddressed, routeMessage };
|
|
140
|
+
export { FOLLOW_UP_WINDOW_MS, addressingReason, decisionRouter, isAddressed, routeMessage };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type RecentMessage } from "./observed.js";
|
|
2
|
+
import type { HistoryReader, SessionOperations } from "./sessions.js";
|
|
3
|
+
/**
|
|
4
|
+
* Recover the agent's questions from the existing session without starting a turn. The local
|
|
5
|
+
* ring supplements this with recipient identities and chatter not written to Eve's stream.
|
|
6
|
+
*/
|
|
7
|
+
export declare function routingContext(input: {
|
|
8
|
+
readonly address: string;
|
|
9
|
+
readonly operations: SessionOperations | undefined;
|
|
10
|
+
readonly read: HistoryReader;
|
|
11
|
+
readonly remembered: ReadonlyArray<RecentMessage>;
|
|
12
|
+
readonly at: string;
|
|
13
|
+
}): Promise<ReadonlyArray<RecentMessage>>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { fileURLToPath as __eveFileURLToPath } from "node:url";
|
|
2
|
+
import { dirname as __eveDirname } from "node:path";
|
|
3
|
+
import { createRequire as __eveCreateRequire } from "node:module";
|
|
4
|
+
const __filename = __eveFileURLToPath(import.meta.url);
|
|
5
|
+
__eveDirname(__filename);
|
|
6
|
+
__eveCreateRequire(import.meta.url);
|
|
7
|
+
import { agentMessage } from "./observed.mjs";
|
|
8
|
+
import "./router.mjs";
|
|
9
|
+
import { isSilentReply } from "./silent.mjs";
|
|
10
|
+
const DELIVERY_CLOCK_TOLERANCE_MS = 6e4;
|
|
11
|
+
async function routingContext(input) {
|
|
12
|
+
if (input.operations === void 0) return input.remembered;
|
|
13
|
+
try {
|
|
14
|
+
const session = await input.operations.resolveSession(input.address);
|
|
15
|
+
if (session === void 0) return input.remembered;
|
|
16
|
+
const events = await input.read(session.id);
|
|
17
|
+
const sentAt = Date.parse(input.at);
|
|
18
|
+
const recovered = [];
|
|
19
|
+
let resetAt = 0;
|
|
20
|
+
for (const event of events) {
|
|
21
|
+
if (event.type === "context.cleared" || event.type === "session.started") {
|
|
22
|
+
recovered.length = 0;
|
|
23
|
+
resetAt = Date.parse(event.meta.at);
|
|
24
|
+
}
|
|
25
|
+
const age = sentAt - Date.parse(event.meta.at);
|
|
26
|
+
const data = event.data;
|
|
27
|
+
if (event.type === "message.completed" && data !== void 0 && data.finishReason !== "tool-calls" && data.message.trim() !== "" && !isSilentReply(data.message) && age >= 0 && age <= 18e5) recovered.push(agentMessage({
|
|
28
|
+
text: data.message,
|
|
29
|
+
at: event.meta.at
|
|
30
|
+
}));
|
|
31
|
+
}
|
|
32
|
+
const remembered = input.remembered.filter((message) => Date.parse(message.at) >= resetAt);
|
|
33
|
+
const knownReplies = remembered.filter((message) => "role" in message);
|
|
34
|
+
return [...recovered.filter((message) => !knownReplies.some((known) => known.text === message.text && Math.abs(Date.parse(known.at) - Date.parse(message.at)) <= DELIVERY_CLOCK_TOLERANCE_MS)), ...remembered].sort((first, second) => Date.parse(first.at) - Date.parse(second.at)).slice(-30);
|
|
35
|
+
} catch (error) {
|
|
36
|
+
console.warn("[segmentstream] routing context could not read the session; using recent messages", { error });
|
|
37
|
+
return input.remembered;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export { routingContext };
|
|
@@ -43,6 +43,11 @@ export interface RecordedEvent {
|
|
|
43
43
|
readonly meta: {
|
|
44
44
|
readonly at: string;
|
|
45
45
|
};
|
|
46
|
+
/** Visible assistant message content, also used to recover smart-routing context. */
|
|
47
|
+
readonly data?: {
|
|
48
|
+
readonly message: string;
|
|
49
|
+
readonly finishReason: string;
|
|
50
|
+
};
|
|
46
51
|
}
|
|
47
52
|
/** The last events of a session, oldest first. Production reads eve's durable stream. */
|
|
48
53
|
export type HistoryReader = (sessionId: string) => Promise<ReadonlyArray<RecordedEvent>>;
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { TelegramChannelEvents } from "eve/channels/telegram";
|
|
2
|
+
import { type ObservedMessages } from "./observed.js";
|
|
2
3
|
export declare function isSilentReply(text: string): boolean;
|
|
3
4
|
type MessageCompleted = NonNullable<TelegramChannelEvents["message.completed"]>;
|
|
4
5
|
/**
|
|
5
6
|
* The channel's `message.completed` handler: eve's default delivery (plain text through
|
|
6
7
|
* `sendMessage`, nothing for a tool-call step) unless the reply is a silence marker.
|
|
7
8
|
*/
|
|
9
|
+
export declare function replyDelivery(observed?: ObservedMessages): MessageCompleted;
|
|
8
10
|
export declare const deliverUnlessSilent: MessageCompleted;
|
|
9
11
|
export {};
|
|
@@ -4,12 +4,37 @@ import { createRequire as __eveCreateRequire } from "node:module";
|
|
|
4
4
|
const __filename = __eveFileURLToPath(import.meta.url);
|
|
5
5
|
__eveDirname(__filename);
|
|
6
6
|
__eveCreateRequire(import.meta.url);
|
|
7
|
+
import "./principal.mjs";
|
|
8
|
+
import { agentMessage, observedChatKey } from "./observed.mjs";
|
|
7
9
|
const SILENT_MARKERS = new Set(["[silent]", "no_reply"]);
|
|
8
10
|
function isSilentReply(text) {
|
|
9
11
|
return SILENT_MARKERS.has(text.trim().toLowerCase());
|
|
10
12
|
}
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
function replyDelivery(observed) {
|
|
14
|
+
return async (event, channel, context) => {
|
|
15
|
+
if (event.finishReason === "tool-calls" || !event.message || isSilentReply(event.message)) return;
|
|
16
|
+
await channel.telegram.post(event.message);
|
|
17
|
+
if (observed === void 0 || channel.telegram.chatType === "private") return;
|
|
18
|
+
const principal = context.session.auth.current;
|
|
19
|
+
const sender = principal?.authenticator === "segmentstream-telegram" ? principal.subject : void 0;
|
|
20
|
+
const username = principal?.attributes.username;
|
|
21
|
+
const name = principal?.attributes.memberName;
|
|
22
|
+
const author = typeof name === "string" ? name : typeof username === "string" ? `@${username}` : sender;
|
|
23
|
+
const inReplyTo = sender === void 0 || author === void 0 ? void 0 : {
|
|
24
|
+
id: sender,
|
|
25
|
+
author,
|
|
26
|
+
...typeof username === "string" ? { username } : {}
|
|
27
|
+
};
|
|
28
|
+
const chat = observedChatKey({
|
|
29
|
+
chatId: channel.telegram.chatId,
|
|
30
|
+
...channel.telegram.messageThreadId === void 0 ? {} : { messageThreadId: channel.telegram.messageThreadId }
|
|
31
|
+
});
|
|
32
|
+
observed.remember(chat, agentMessage({
|
|
33
|
+
text: event.message,
|
|
34
|
+
at: new Date().toISOString(),
|
|
35
|
+
...inReplyTo === void 0 ? {} : { inReplyTo }
|
|
36
|
+
}));
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const deliverUnlessSilent = replyDelivery();
|
|
40
|
+
export { deliverUnlessSilent, isSilentReply, replyDelivery };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { TelegramChannelEvents, TelegramChannelState } from "eve/channels/telegram";
|
|
2
|
+
import type { SessionContext } from "eve/context";
|
|
3
|
+
/** The adapter callback shape; Eve's public channel handler is wrapped inside this callback. */
|
|
4
|
+
export type TurnFailedHandler = (event: Parameters<NonNullable<TelegramChannelEvents["turn.failed"]>>[0], context: {
|
|
5
|
+
readonly state: TelegramChannelState;
|
|
6
|
+
readonly session: Pick<SessionContext["session"], "id" | "auth">;
|
|
7
|
+
}) => void | Promise<void>;
|
|
8
|
+
/** Log the original failure before Eve delivers its existing customer-visible error notice. */
|
|
9
|
+
export declare function withTurnErrorLogging(deliver: TurnFailedHandler): TurnFailedHandler;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { fileURLToPath as __eveFileURLToPath } from "node:url";
|
|
2
|
+
import { dirname as __eveDirname } from "node:path";
|
|
3
|
+
import { createRequire as __eveCreateRequire } from "node:module";
|
|
4
|
+
const __filename = __eveFileURLToPath(import.meta.url);
|
|
5
|
+
__eveDirname(__filename);
|
|
6
|
+
__eveCreateRequire(import.meta.url);
|
|
7
|
+
import { memberIdOf } from "./principal.mjs";
|
|
8
|
+
function withTurnErrorLogging(deliver) {
|
|
9
|
+
return async (event, context) => {
|
|
10
|
+
console.error("[segmentstream] telegram turn failed", {
|
|
11
|
+
chatId: context.state.chatId,
|
|
12
|
+
memberId: memberIdOf(context.session.auth.current) ?? null,
|
|
13
|
+
sessionId: context.session.id,
|
|
14
|
+
turnId: event.turnId,
|
|
15
|
+
error: event
|
|
16
|
+
});
|
|
17
|
+
await deliver(event, context);
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export { withTurnErrorLogging };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@segmentstream/agent",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Mount a SegmentStream workspace's agent on eve: the ontology as tools, acting as the member who writes, and Telegram through the workspace's connection.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "SegmentStream",
|