@segmentstream/agent 0.3.0 → 0.3.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/_manifest.json +2 -1
- package/dist/extension/instructions.md +21 -6
- package/dist/extension/lib/confirmations.d.ts +26 -0
- package/dist/extension/lib/confirmations.mjs +96 -0
- package/dist/extension/lib/ontology-connection.d.ts +1 -0
- package/dist/extension/lib/ontology-connection.mjs +3 -0
- package/dist/extension/lib/ontology-tools.d.ts +11 -0
- package/dist/extension/lib/ontology-tools.mjs +41 -0
- package/dist/extension/lib/questions.d.ts +14 -0
- package/dist/extension/lib/questions.mjs +30 -0
- package/dist/extension/lib/telegram/adapter.d.ts +1 -1
- package/dist/extension/lib/telegram/adapter.mjs +31 -4
- package/dist/extension/lib/telegram/confirmations.d.ts +18 -0
- package/dist/extension/lib/telegram/confirmations.mjs +280 -0
- package/dist/extension/lib/telegram/inbound-request.d.ts +4 -0
- package/dist/extension/lib/telegram/inbound-request.mjs +2 -0
- package/dist/extension/lib/telegram/inbound.d.ts +3 -0
- package/dist/extension/lib/telegram/inbound.mjs +50 -9
- package/dist/extension/lib/telegram/principal.mjs +1 -0
- package/dist/extension/lib/telegram/requests.mjs +3 -1
- package/dist/extension/lib/telegram/router.d.ts +1 -1
- package/dist/extension/lib/telegram/routing-context.mjs +2 -2
- package/dist/extension/lib/telegram/sessions.d.ts +4 -7
- package/dist/extension/tools/attachments_promote.mjs +3 -0
- package/dist/extension/tools/questions_ask.d.ts +12 -0
- package/dist/extension/tools/questions_ask.mjs +50 -0
- package/dist/tools/index.d.ts +1 -0
- package/dist/tools/index.mjs +2 -1
- package/package.json +1 -1
|
@@ -7,15 +7,30 @@ talking to you, and you act with exactly their permissions.
|
|
|
7
7
|
connection. Call `describe` for the catalog of entities and actions, and `me` for whom you act
|
|
8
8
|
for and what they may do. The tools you see are already narrowed to that member's permissions;
|
|
9
9
|
if a tool is missing, the member may not use it, so say so instead of looking for a way around.
|
|
10
|
-
-
|
|
10
|
+
- When the current member has permission and gives a clear instruction, act, including necessary
|
|
11
|
+
routine intermediate steps. Their authorization continues across turns. When they answer your
|
|
12
|
+
question, use the answer and continue the pending task without asking for permission again.
|
|
13
|
+
- Ask only when information is missing or ambiguous. In Telegram, use
|
|
14
|
+
`segmentstream__questions_ask` with the question and pending task so the session keeps its intent,
|
|
15
|
+
member and chat/topic. An answer to a pending question is never chatter or `[SILENT]`.
|
|
16
|
+
- Destructive or irreversible actions, and legacy custom actions without safety labels, require
|
|
17
|
+
the tool boundary's Confirm / Cancel buttons. Call the action to start that flow; never ask for
|
|
18
|
+
approval in prose or treat a text acknowledgement as approval. Wait for the button outcome.
|
|
19
|
+
Cancel ends that call; retry it only after a new instruction from the member.
|
|
20
|
+
- Every change goes through an action. Report results after success; never claim a record changed
|
|
21
|
+
unless an action reported it.
|
|
11
22
|
- Never invent records or values. Say when the ontology does not hold what was asked.
|
|
12
23
|
- When you have been routed a message but a reply would add nothing, answer with exactly
|
|
13
24
|
`[SILENT]` and nothing else. Use it only in that case; it suppresses delivery to the chat.
|
|
14
|
-
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
25
|
+
- Use the ontology action `agent_chat.send_message` only to start a conversation in a different
|
|
26
|
+
group or private chat; their replies come back into that chat's session, where you handle them.
|
|
27
|
+
Inside a delivered `<agent_request>`, this turn already runs in the target chat: your reply here
|
|
28
|
+
is the message delivered to that chat. Do not look for or call `agent_chat.send_message` to deliver
|
|
29
|
+
it, even if the ontology catalog is empty or that action is unavailable. If the addressed person
|
|
30
|
+
has a linked Telegram username, mention them with @username; otherwise address them by name
|
|
31
|
+
without a tag. Never invent a username. The groups you are in are listed in `agent_chat`, with the
|
|
32
|
+
`purpose` and `whenToUse` members set; when a member who may change it tells you what a group is
|
|
33
|
+
for, record it with `agent_chat.update`.
|
|
19
34
|
- Files the member sends are temporary attachments, listed in an `<attachments>` block with their
|
|
20
35
|
ids and kept for 24 hours. `segmentstream__attachments_read` extracts sheet tables, document text,
|
|
21
36
|
email bodies and archive listings, with images/PDF inline and metadata for other formats. Follow
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type StateHandle } from "eve/context";
|
|
2
|
+
import type { ApprovalConfiguration } from "eve/tools/approval";
|
|
3
|
+
export declare const CONFIRMATION_TTL_MS: number;
|
|
4
|
+
export interface ActionAnnotations {
|
|
5
|
+
readonly readOnlyHint?: boolean;
|
|
6
|
+
readonly destructiveHint?: boolean;
|
|
7
|
+
readonly irreversibleHint?: boolean;
|
|
8
|
+
readonly idempotentHint?: boolean;
|
|
9
|
+
}
|
|
10
|
+
/** Safety comes from the authenticated ontology, never from the name or model prose. */
|
|
11
|
+
export declare function needsConfirmation(labels: ActionAnnotations): boolean;
|
|
12
|
+
export declare function inputHash(input: unknown): string;
|
|
13
|
+
export interface Confirmation {
|
|
14
|
+
readonly memberId: string;
|
|
15
|
+
readonly action: string;
|
|
16
|
+
readonly inputHash: string;
|
|
17
|
+
readonly expiresAt: number;
|
|
18
|
+
readonly status: "pending" | "approved" | "consumed" | "cancelled";
|
|
19
|
+
}
|
|
20
|
+
export declare const confirmations: StateHandle<Record<string, Confirmation>>;
|
|
21
|
+
/** Eve pauses the original call and rechecks this policy immediately before executing it. */
|
|
22
|
+
export declare function actionApproval(options: {
|
|
23
|
+
readonly labels: (memberId: string, action: string, input: Readonly<Record<string, unknown>>) => Promise<ActionAnnotations>;
|
|
24
|
+
readonly state?: StateHandle<Record<string, Confirmation>>;
|
|
25
|
+
readonly now?: () => number;
|
|
26
|
+
}): ApprovalConfiguration;
|
|
@@ -0,0 +1,96 @@
|
|
|
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 "./telegram/principal.mjs";
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { defineState } from "eve/context";
|
|
10
|
+
const CONFIRMATION_TTL_MS = 6e5;
|
|
11
|
+
function needsConfirmation(labels) {
|
|
12
|
+
return labels.readOnlyHint !== true && (labels.destructiveHint === true || labels.irreversibleHint === true || labels.destructiveHint === void 0 && labels.irreversibleHint === void 0);
|
|
13
|
+
}
|
|
14
|
+
function canonical(value) {
|
|
15
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
|
16
|
+
if (typeof value === "object" && value !== null) return `{${Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([key, item]) => `${JSON.stringify(key)}:${canonical(item)}`).join(",")}}`;
|
|
17
|
+
const encoded = JSON.stringify(value);
|
|
18
|
+
if (encoded === void 0) throw new TypeError("Action input must be JSON.");
|
|
19
|
+
return encoded;
|
|
20
|
+
}
|
|
21
|
+
function inputHash(input) {
|
|
22
|
+
return createHash("sha256").update(canonical(input)).digest("hex");
|
|
23
|
+
}
|
|
24
|
+
const confirmations = defineState("segmentstream.confirmations", () => ({}));
|
|
25
|
+
function actionApproval(options) {
|
|
26
|
+
const state = options.state ?? confirmations;
|
|
27
|
+
const now = options.now ?? Date.now;
|
|
28
|
+
const denied = (reason) => ({
|
|
29
|
+
type: "denied",
|
|
30
|
+
reason
|
|
31
|
+
});
|
|
32
|
+
return {
|
|
33
|
+
async request(ctx) {
|
|
34
|
+
const memberId = memberIdOf(ctx.session.auth.current);
|
|
35
|
+
if (memberId === void 0) return denied("A paired member is required.");
|
|
36
|
+
const hash = inputHash(ctx.toolInput ?? {});
|
|
37
|
+
const saved = state.get()[ctx.callId];
|
|
38
|
+
if (saved !== void 0) {
|
|
39
|
+
if (saved.memberId !== memberId || saved.action !== ctx.toolName || saved.inputHash !== hash) return denied("The confirmation belongs to another member, action or input.");
|
|
40
|
+
if (saved.expiresAt <= now()) return denied("The confirmation expired. Make a new call.");
|
|
41
|
+
if (saved.status === "consumed" || saved.status === "cancelled") return denied("The confirmation has already been used or cancelled.");
|
|
42
|
+
}
|
|
43
|
+
let labels;
|
|
44
|
+
try {
|
|
45
|
+
labels = await options.labels(memberId, ctx.toolName, ctx.toolInput ?? {});
|
|
46
|
+
} catch (error) {
|
|
47
|
+
console.warn("The ontology action permission check failed.", error);
|
|
48
|
+
return denied("The action is unavailable or the member no longer has permission.");
|
|
49
|
+
}
|
|
50
|
+
if (saved?.status === "approved") {
|
|
51
|
+
state.update((all) => ({
|
|
52
|
+
...all,
|
|
53
|
+
[ctx.callId]: {
|
|
54
|
+
...saved,
|
|
55
|
+
status: "consumed"
|
|
56
|
+
}
|
|
57
|
+
}));
|
|
58
|
+
return "approved";
|
|
59
|
+
}
|
|
60
|
+
if (saved !== void 0) return denied("The original call is waiting for its Confirm / Cancel buttons.");
|
|
61
|
+
if (!needsConfirmation(labels)) return "approved";
|
|
62
|
+
state.update((all) => ({
|
|
63
|
+
...all,
|
|
64
|
+
[ctx.callId]: {
|
|
65
|
+
memberId,
|
|
66
|
+
action: ctx.toolName,
|
|
67
|
+
inputHash: hash,
|
|
68
|
+
expiresAt: now() + CONFIRMATION_TTL_MS,
|
|
69
|
+
status: "pending"
|
|
70
|
+
}
|
|
71
|
+
}));
|
|
72
|
+
return "user-approval";
|
|
73
|
+
},
|
|
74
|
+
response(ctx) {
|
|
75
|
+
const saved = state.get()[ctx.request.callId];
|
|
76
|
+
const reject = (reason) => ({
|
|
77
|
+
status: "rejected",
|
|
78
|
+
reason
|
|
79
|
+
});
|
|
80
|
+
if (saved === void 0 || saved.status !== "pending") return reject("This confirmation is no longer pending.");
|
|
81
|
+
if (ctx.response.principal.attributes.confirmationRequestId !== ctx.request.requestId || ctx.response.principal.attributes.confirmationDecision !== ctx.response.decision) return reject("Use the Confirm or Cancel button on the action's message.");
|
|
82
|
+
if (memberIdOf(ctx.response.principal) !== saved.memberId || memberIdOf(ctx.request.principal) !== saved.memberId) return reject("Only the requesting member can confirm or cancel this action.");
|
|
83
|
+
if (saved.action !== ctx.request.toolName || saved.inputHash !== inputHash(ctx.request.toolInput ?? {})) return reject("The action or input changed. Make a new call.");
|
|
84
|
+
if (ctx.response.decision === "approve" && saved.expiresAt <= now()) return reject("This confirmation expired.");
|
|
85
|
+
state.update((all) => ({
|
|
86
|
+
...all,
|
|
87
|
+
[ctx.request.callId]: {
|
|
88
|
+
...saved,
|
|
89
|
+
status: ctx.response.decision === "approve" ? "approved" : "cancelled"
|
|
90
|
+
}
|
|
91
|
+
}));
|
|
92
|
+
return { status: "allowed" };
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
export { CONFIRMATION_TTL_MS, actionApproval, confirmations, inputHash, needsConfirmation };
|
|
@@ -6,7 +6,9 @@ __eveDirname(__filename);
|
|
|
6
6
|
__eveCreateRequire(import.meta.url);
|
|
7
7
|
import { appOrigin } from "./environment.mjs";
|
|
8
8
|
import { memberIdOf } from "./telegram/principal.mjs";
|
|
9
|
+
import { actionApproval } from "./confirmations.mjs";
|
|
9
10
|
import { runtimeToken, tokenExpiry } from "./runtime-token.mjs";
|
|
11
|
+
import { ontologyToolLabels } from "./ontology-tools.mjs";
|
|
10
12
|
import { defineMcpClientConnection } from "eve/connections";
|
|
11
13
|
const ACT_AS_HEADER = "X-SegmentStream-Act-As";
|
|
12
14
|
const DESCRIPTION = "The company's ontology: its records, their history, and the actions that change them, narrowed to what the member you act for may read and do. Call describe for the catalog and me for the member.";
|
|
@@ -28,6 +30,7 @@ function ontologyConnection(input) {
|
|
|
28
30
|
url: `${origin}/mcp`,
|
|
29
31
|
description: DESCRIPTION,
|
|
30
32
|
instanceKey: `${origin}/mcp#member:${memberId}`,
|
|
33
|
+
approval: actionApproval({ labels: ontologyToolLabels(input) }),
|
|
31
34
|
auth: {
|
|
32
35
|
credentialOwner: "app",
|
|
33
36
|
getToken: async () => {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ActionAnnotations } from "./confirmations.js";
|
|
2
|
+
import { type Environment } from "./environment.js";
|
|
3
|
+
import { type RuntimeToken } from "./runtime-token.js";
|
|
4
|
+
/** A fresh, permission-filtered catalog. Listing tools cannot run an action's prepare step. */
|
|
5
|
+
export declare function ontologyToolLabels(options?: {
|
|
6
|
+
readonly environment?: Environment;
|
|
7
|
+
readonly token?: RuntimeToken;
|
|
8
|
+
readonly fetch?: typeof fetch;
|
|
9
|
+
}): (memberId: string, action: string) => Promise<ActionAnnotations>;
|
|
10
|
+
/** MCP normalizes an authored camelCase action to snake_case. */
|
|
11
|
+
export declare function actionToolName(entity: string, action: string): string;
|
|
@@ -0,0 +1,41 @@
|
|
|
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 { appOrigin } from "./environment.mjs";
|
|
8
|
+
import { runtimeToken } from "./runtime-token.mjs";
|
|
9
|
+
function ontologyToolLabels(options = {}) {
|
|
10
|
+
return async (memberId, action) => {
|
|
11
|
+
const response = await (options.fetch ?? fetch)(`${appOrigin(options.environment ?? process.env)}/mcp`, {
|
|
12
|
+
method: "POST",
|
|
13
|
+
headers: {
|
|
14
|
+
authorization: `Bearer ${await (options.token ?? runtimeToken)()}`,
|
|
15
|
+
"X-SegmentStream-Act-As": memberId,
|
|
16
|
+
"content-type": "application/json",
|
|
17
|
+
accept: "application/json, text/event-stream",
|
|
18
|
+
"mcp-protocol-version": "2025-11-25"
|
|
19
|
+
},
|
|
20
|
+
body: JSON.stringify({
|
|
21
|
+
jsonrpc: "2.0",
|
|
22
|
+
id: "approval-tools",
|
|
23
|
+
method: "tools/list",
|
|
24
|
+
params: {}
|
|
25
|
+
}),
|
|
26
|
+
signal: AbortSignal.timeout(15e3),
|
|
27
|
+
redirect: "error"
|
|
28
|
+
});
|
|
29
|
+
if (!response.ok) throw new Error("Cannot check action permission.");
|
|
30
|
+
const body = await response.json();
|
|
31
|
+
const bareName = action.split("__").at(-1);
|
|
32
|
+
const tool = body.result?.tools?.find((candidate) => candidate.name === bareName);
|
|
33
|
+
if (tool === void 0) throw new Error("Action not permitted.");
|
|
34
|
+
return tool.annotations ?? {};
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function actionToolName(entity, action) {
|
|
38
|
+
const snake = (name) => name.replace(/([A-Z])([A-Z][a-z])/g, "$1_$2").replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase();
|
|
39
|
+
return `${snake(entity)}_${snake(action)}`;
|
|
40
|
+
}
|
|
41
|
+
export { actionToolName, ontologyToolLabels };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface PendingQuestion {
|
|
2
|
+
readonly id: string;
|
|
3
|
+
readonly question: string;
|
|
4
|
+
readonly task: string;
|
|
5
|
+
readonly memberId: string;
|
|
6
|
+
readonly chatId: string;
|
|
7
|
+
readonly messageThreadId: string;
|
|
8
|
+
readonly address: string;
|
|
9
|
+
}
|
|
10
|
+
/** Intent survives turns and compaction in the session, independently of model history. */
|
|
11
|
+
export declare const pendingQuestions: import("eve/context").StateHandle<Record<string, PendingQuestion>>;
|
|
12
|
+
export declare function questionKey(question: Pick<PendingQuestion, "memberId" | "chatId" | "messageThreadId">): string;
|
|
13
|
+
export declare function isPendingQuestion(value: unknown): value is PendingQuestion;
|
|
14
|
+
export declare function answerMarker(id: string): string;
|
|
@@ -0,0 +1,30 @@
|
|
|
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 { defineState } from "eve/context";
|
|
8
|
+
const pendingQuestions = defineState("segmentstream.pending-questions", () => ({}));
|
|
9
|
+
function questionKey(question) {
|
|
10
|
+
return JSON.stringify([
|
|
11
|
+
question.memberId,
|
|
12
|
+
question.chatId,
|
|
13
|
+
question.messageThreadId
|
|
14
|
+
]);
|
|
15
|
+
}
|
|
16
|
+
function isPendingQuestion(value) {
|
|
17
|
+
return typeof value === "object" && value !== null && [
|
|
18
|
+
"id",
|
|
19
|
+
"question",
|
|
20
|
+
"task",
|
|
21
|
+
"memberId",
|
|
22
|
+
"chatId",
|
|
23
|
+
"messageThreadId",
|
|
24
|
+
"address"
|
|
25
|
+
].every((key) => typeof value[key] === "string");
|
|
26
|
+
}
|
|
27
|
+
function answerMarker(id) {
|
|
28
|
+
return `[Answer to pending question ${id}]`;
|
|
29
|
+
}
|
|
30
|
+
export { answerMarker, isPendingQuestion, pendingQuestions, questionKey };
|
|
@@ -31,7 +31,7 @@ export interface TelegramAdapterOptions {
|
|
|
31
31
|
readonly observed?: ObservedMessages;
|
|
32
32
|
/** Verifies callers of the messages route; tests pass one on a local key set. */
|
|
33
33
|
readonly verify?: RuntimeTokenVerifier;
|
|
34
|
-
/** Reads
|
|
34
|
+
/** Reads session events for routing, policy and interactions; tests pass a fake. */
|
|
35
35
|
readonly history?: HistoryReader;
|
|
36
36
|
/** The attachment store; tests pass a fake. */
|
|
37
37
|
readonly attachments?: AgentAttachments;
|
|
@@ -11,14 +11,15 @@ import { agentAttachments, parseAttachmentUrl } from "../attachments.mjs";
|
|
|
11
11
|
import { agentIdentities } from "../identities.mjs";
|
|
12
12
|
import { runtimeTokenVerifier } from "../runtime-identity.mjs";
|
|
13
13
|
import { botIdentityCache } from "./bot.mjs";
|
|
14
|
+
import { InboundRequest, currentInboundRequest, runInboundRequest, withReplacedText } from "./inbound-request.mjs";
|
|
15
|
+
import { scopedAddress, sessionPolicy } from "./sessions.mjs";
|
|
16
|
+
import { confirmationEvents, handleConfirmationCallback } from "./confirmations.mjs";
|
|
14
17
|
import { agentChats } from "./chats.mjs";
|
|
15
18
|
import { observedMessages } from "./observed.mjs";
|
|
16
19
|
import { decisionRouter } from "./router.mjs";
|
|
17
20
|
import { replyDelivery } from "./silent.mjs";
|
|
18
|
-
import { scopedAddress, sessionPolicy } from "./sessions.mjs";
|
|
19
21
|
import { downloadTelegramFile, gatewayTranscriber } from "./voice.mjs";
|
|
20
22
|
import { handleInboundMessage } from "./inbound.mjs";
|
|
21
|
-
import { InboundRequest, currentInboundRequest, runInboundRequest, withReplacedText } from "./inbound-request.mjs";
|
|
22
23
|
import { TELEGRAM_PLACEHOLDER_TOKEN, TELEGRAM_PROXY_ORIGIN, telegramProxyFetch } from "./proxy-fetch.mjs";
|
|
23
24
|
import { messagesRoute } from "./requests.mjs";
|
|
24
25
|
import { withTurnErrorLogging } from "./turn-errors.mjs";
|
|
@@ -40,11 +41,22 @@ const eveHistory = async (sessionId) => {
|
|
|
40
41
|
});
|
|
41
42
|
return events;
|
|
42
43
|
};
|
|
44
|
+
const eveInteractions = async (sessionId) => {
|
|
45
|
+
const { sessions } = await import("eve/server");
|
|
46
|
+
const events = [];
|
|
47
|
+
for await (const event of sessions.attach(sessionId).stream({ follow: false })) events.push({
|
|
48
|
+
type: event.type,
|
|
49
|
+
meta: { at: event.meta.at },
|
|
50
|
+
data: event.data
|
|
51
|
+
});
|
|
52
|
+
return events;
|
|
53
|
+
};
|
|
43
54
|
function segmentstreamTelegram(options) {
|
|
44
55
|
const environment = options.environment ?? process.env;
|
|
45
56
|
const token = options.token ?? runtimeToken;
|
|
46
57
|
const observed = options.observed ?? observedMessages();
|
|
47
58
|
const readHistory = options.history ?? eveHistory;
|
|
59
|
+
const readInteractions = options.history ?? eveInteractions;
|
|
48
60
|
const policy = sessionPolicy({
|
|
49
61
|
settings: options.sessions,
|
|
50
62
|
read: readHistory
|
|
@@ -124,11 +136,22 @@ function segmentstreamTelegram(options) {
|
|
|
124
136
|
attachments: attachments(),
|
|
125
137
|
observed,
|
|
126
138
|
readHistory,
|
|
139
|
+
history: readInteractions,
|
|
127
140
|
chats,
|
|
128
141
|
connectUrl: (code) => `${appOrigin(environment)}/agent/connect?code=${encodeURIComponent(code)}`
|
|
129
142
|
}
|
|
130
143
|
}),
|
|
131
|
-
|
|
144
|
+
onCallbackQuery: (context, query) => handleConfirmationCallback({
|
|
145
|
+
context,
|
|
146
|
+
query,
|
|
147
|
+
identities: pairing(),
|
|
148
|
+
read: readInteractions,
|
|
149
|
+
scope: options.sessions().scope
|
|
150
|
+
}),
|
|
151
|
+
events: {
|
|
152
|
+
...confirmationEvents,
|
|
153
|
+
"message.completed": replyDelivery(observed)
|
|
154
|
+
}
|
|
132
155
|
});
|
|
133
156
|
const adapter = channel.adapter;
|
|
134
157
|
const fetchTelegramFile = adapter?.fetchFile;
|
|
@@ -188,9 +211,13 @@ function gatedRoute(input) {
|
|
|
188
211
|
from: args.from
|
|
189
212
|
};
|
|
190
213
|
inbound.waitUntil = args.waitUntil;
|
|
214
|
+
inbound.respond = (address, requestId, decision, auth) => args.from(scoped(address)).respond([{
|
|
215
|
+
requestId,
|
|
216
|
+
optionId: decision
|
|
217
|
+
}], { auth });
|
|
191
218
|
return runInboundRequest(inbound, () => route.handler(request, {
|
|
192
219
|
...args,
|
|
193
|
-
from: (address) => withReplacedText(args.from(scoped(address)), inbound)
|
|
220
|
+
from: (address) => withReplacedText(args.from(inbound.address ?? scoped(address)), inbound)
|
|
194
221
|
}));
|
|
195
222
|
}
|
|
196
223
|
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ChannelEvents } from "eve/channels";
|
|
2
|
+
import type { TelegramCallbackQuery, TelegramChannelEvents, TelegramContext, TelegramEventContext } from "eve/channels/telegram";
|
|
3
|
+
import type { AgentIdentities } from "../identities.js";
|
|
4
|
+
import { type PendingQuestion } from "../questions.js";
|
|
5
|
+
import { type HistoryReader, type RecordedEvent } from "./sessions.js";
|
|
6
|
+
export declare function confirmationCallback(requestId: string, memberId: string, decision: "approve" | "cancel"): string;
|
|
7
|
+
export declare function questionFromHistory(events: readonly RecordedEvent[], owner?: Pick<PendingQuestion, "memberId" | "chatId" | "messageThreadId">): PendingQuestion | undefined;
|
|
8
|
+
export declare function questionAddress(owner: Pick<PendingQuestion, "memberId" | "chatId" | "messageThreadId">): string;
|
|
9
|
+
/** Custom callback data avoids Eve's default, unpaired Telegram callback principal. */
|
|
10
|
+
export declare function handleConfirmationCallback(input: {
|
|
11
|
+
readonly context: TelegramContext;
|
|
12
|
+
readonly query: TelegramCallbackQuery;
|
|
13
|
+
readonly identities: AgentIdentities;
|
|
14
|
+
readonly read: HistoryReader;
|
|
15
|
+
readonly scope: "chat" | "thread";
|
|
16
|
+
readonly now?: number;
|
|
17
|
+
}): Promise<void>;
|
|
18
|
+
export declare const confirmationEvents: TelegramChannelEvents & Pick<ChannelEvents<TelegramEventContext>, "context.cleared">;
|
|
@@ -0,0 +1,280 @@
|
|
|
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, telegramPrincipal } from "./principal.mjs";
|
|
8
|
+
import { confirmations } from "../confirmations.mjs";
|
|
9
|
+
import { answerMarker, isPendingQuestion, pendingQuestions, questionKey } from "../questions.mjs";
|
|
10
|
+
import { currentInboundRequest } from "./inbound-request.mjs";
|
|
11
|
+
import { formatAddress, scopedAddress } from "./sessions.mjs";
|
|
12
|
+
import { registerTelegramFreeformPrompt, renderTelegramInputRequest } from "eve/channels/telegram";
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
const PREFIX = "ss-confirm:";
|
|
15
|
+
function shortHash(value) {
|
|
16
|
+
return createHash("sha256").update(value).digest("hex").slice(0, 20);
|
|
17
|
+
}
|
|
18
|
+
function confirmationCallback(requestId, memberId, decision) {
|
|
19
|
+
return `${PREFIX}${shortHash(requestId)}:${shortHash(memberId)}:${decision === "approve" ? "y" : "n"}`;
|
|
20
|
+
}
|
|
21
|
+
function object(value) {
|
|
22
|
+
return typeof value === "object" && value !== null ? value : {};
|
|
23
|
+
}
|
|
24
|
+
function openConfirmations(events) {
|
|
25
|
+
const open = new Map();
|
|
26
|
+
for (const event of events) {
|
|
27
|
+
const data = object(event.data);
|
|
28
|
+
if (event.type === "context.cleared") open.clear();
|
|
29
|
+
if (event.type === "input.requested" && Array.isArray(data.requests)) for (const item of data.requests) {
|
|
30
|
+
const request = object(item);
|
|
31
|
+
const action = object(request.action);
|
|
32
|
+
if (request.kind === "tool-approval" && typeof request.requestId === "string" && typeof action.callId === "string") open.set(request.requestId, {
|
|
33
|
+
requestId: request.requestId,
|
|
34
|
+
callId: action.callId,
|
|
35
|
+
at: Date.parse(event.meta.at)
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
if (event.type === "input.resolved" && Array.isArray(data.resolutions)) for (const item of data.resolutions) {
|
|
39
|
+
const id = object(item).requestId;
|
|
40
|
+
if (typeof id === "string") open.delete(id);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return [...open.values()];
|
|
44
|
+
}
|
|
45
|
+
function questionFromHistory(events, owner) {
|
|
46
|
+
const pending = new Map();
|
|
47
|
+
for (const event of events) {
|
|
48
|
+
const data = object(event.data);
|
|
49
|
+
if (event.type === "context.cleared") pending.clear();
|
|
50
|
+
if (event.type === "action.result") {
|
|
51
|
+
const result = object(data.result);
|
|
52
|
+
const candidate = object(result.output).pendingQuestion;
|
|
53
|
+
if (typeof result.toolName === "string" && result.toolName.endsWith("__questions_ask") && isPendingQuestion(candidate)) pending.set(questionKey(candidate), candidate);
|
|
54
|
+
}
|
|
55
|
+
if (event.type === "message.received" && typeof data.message === "string" && data.message.startsWith("[Answer to pending question ")) {
|
|
56
|
+
for (const [key, question] of pending) if (data.message.startsWith(answerMarker(question.id))) pending.delete(key);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return owner === void 0 ? [...pending.values()].at(-1) : pending.get(questionKey(owner));
|
|
60
|
+
}
|
|
61
|
+
function questionAddress(owner) {
|
|
62
|
+
return formatAddress({
|
|
63
|
+
...owner,
|
|
64
|
+
conversationId: `question-${shortHash(owner.memberId)}`
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
async function handleConfirmationCallback(input) {
|
|
68
|
+
const { context, query } = input;
|
|
69
|
+
const notice = (text) => context.telegram.answerCallbackQuery({
|
|
70
|
+
callbackQueryId: query.id,
|
|
71
|
+
text
|
|
72
|
+
});
|
|
73
|
+
if (!query.data?.startsWith(PREFIX) || query.message === void 0 || query.from.isBot) {
|
|
74
|
+
await notice("Unsupported action.");
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const [requestHash, memberHash, choice] = query.data.slice(11).split(":");
|
|
78
|
+
const lookup = await input.identities.lookup({
|
|
79
|
+
provider: "telegram",
|
|
80
|
+
externalId: query.from.id
|
|
81
|
+
});
|
|
82
|
+
if (lookup.status !== "paired" || shortHash(lookup.member.id) !== memberHash) {
|
|
83
|
+
await notice("Only the requesting member can confirm or cancel this action.");
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const address = scopedAddress(formatAddress({
|
|
87
|
+
chatId: query.message.chat.id,
|
|
88
|
+
messageThreadId: String(query.message.messageThreadId ?? ""),
|
|
89
|
+
conversationId: query.message.chat.type === "private" ? "" : query.message.messageId
|
|
90
|
+
}), input.scope);
|
|
91
|
+
const session = await (currentInboundRequest()?.sessions)?.resolveSession(address);
|
|
92
|
+
const prompt = session === void 0 ? void 0 : openConfirmations(await input.read(session.id)).find((candidate) => shortHash(candidate.requestId) === requestHash);
|
|
93
|
+
if (prompt === void 0 || choice !== "y" && choice !== "n") {
|
|
94
|
+
await notice("This confirmation is no longer pending.");
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const expired = !Number.isFinite(prompt.at) || prompt.at + 6e5 <= (input.now ?? Date.now());
|
|
98
|
+
const decision = expired || choice === "n" ? "cancel" : "approve";
|
|
99
|
+
const principal = telegramPrincipal({
|
|
100
|
+
member: lookup.member,
|
|
101
|
+
message: {
|
|
102
|
+
...query.message,
|
|
103
|
+
from: query.from,
|
|
104
|
+
text: "",
|
|
105
|
+
caption: "",
|
|
106
|
+
raw: {},
|
|
107
|
+
attachments: []
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
const source = currentInboundRequest()?.respond;
|
|
111
|
+
if (source === void 0) throw new Error("Confirmation callback has no session continuation.");
|
|
112
|
+
await source(address, prompt.requestId, decision, {
|
|
113
|
+
...principal,
|
|
114
|
+
attributes: {
|
|
115
|
+
...principal.attributes,
|
|
116
|
+
confirmationRequestId: prompt.requestId,
|
|
117
|
+
confirmationDecision: decision
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
await notice(expired ? "This confirmation expired; nothing executed." : decision === "cancel" ? "Cancellation received." : "Confirmation received.");
|
|
121
|
+
}
|
|
122
|
+
const confirmationEvents = {
|
|
123
|
+
"context.cleared"(_data, channel) {
|
|
124
|
+
confirmations.update(() => ({}));
|
|
125
|
+
pendingQuestions.update(() => ({}));
|
|
126
|
+
const state = channel.state;
|
|
127
|
+
state.confirmationMessages = {};
|
|
128
|
+
state.pendingQuestions = {};
|
|
129
|
+
},
|
|
130
|
+
async "input.requested"(data, channel, ctx) {
|
|
131
|
+
for (const request of data.requests) {
|
|
132
|
+
if (request.kind !== "tool-approval") {
|
|
133
|
+
const rendered = renderTelegramInputRequest(request, channel.state);
|
|
134
|
+
const sent = await channel.telegram.post({
|
|
135
|
+
text: rendered.text,
|
|
136
|
+
reply_markup: rendered.replyMarkup
|
|
137
|
+
});
|
|
138
|
+
if (rendered.freeformRequestId !== void 0) registerTelegramFreeformPrompt(channel.state, {
|
|
139
|
+
messageId: sent.id,
|
|
140
|
+
requestId: request.requestId
|
|
141
|
+
});
|
|
142
|
+
else channel.state.hitlPrompts = {
|
|
143
|
+
...channel.state.hitlPrompts,
|
|
144
|
+
[request.requestId]: {
|
|
145
|
+
messageId: sent.id,
|
|
146
|
+
text: rendered.text,
|
|
147
|
+
options: (request.options ?? []).map(({ id, label }) => ({
|
|
148
|
+
id,
|
|
149
|
+
label
|
|
150
|
+
}))
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const memberId = confirmations.get()[request.action.callId]?.memberId ?? memberIdOf(ctx.session.auth.current);
|
|
156
|
+
if (memberId === void 0) throw new Error("Confirmation requires a member.");
|
|
157
|
+
const text = `Confirm ${request.action.toolName}?\n${JSON.stringify(request.action.input, null, 2)}`.slice(0, 3900);
|
|
158
|
+
const sent = await channel.telegram.post({
|
|
159
|
+
text,
|
|
160
|
+
reply_markup: { inline_keyboard: [[{
|
|
161
|
+
text: "Confirm",
|
|
162
|
+
callback_data: confirmationCallback(request.requestId, memberId, "approve")
|
|
163
|
+
}, {
|
|
164
|
+
text: "Cancel",
|
|
165
|
+
callback_data: confirmationCallback(request.requestId, memberId, "cancel")
|
|
166
|
+
}]] }
|
|
167
|
+
});
|
|
168
|
+
const state = channel.state;
|
|
169
|
+
state.confirmationMessages = {
|
|
170
|
+
...state.confirmationMessages,
|
|
171
|
+
[request.action.callId]: {
|
|
172
|
+
requestId: request.requestId,
|
|
173
|
+
messageId: sent.id,
|
|
174
|
+
text
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
async "input.resolved"(data, channel) {
|
|
180
|
+
const state = channel.state;
|
|
181
|
+
for (const resolution of data.resolutions) {
|
|
182
|
+
const entry = Object.entries(state.confirmationMessages ?? {}).find(([, message]) => message.requestId === resolution.requestId);
|
|
183
|
+
if (entry === void 0) {
|
|
184
|
+
const prompt = channel.state.hitlPrompts?.[resolution.requestId];
|
|
185
|
+
if (prompt !== void 0) {
|
|
186
|
+
const response = resolution.response;
|
|
187
|
+
const label = response?.text ?? prompt.options.find((option) => option.id === response?.optionId)?.label;
|
|
188
|
+
await channel.telegram.editMessageText({
|
|
189
|
+
messageId: prompt.messageId,
|
|
190
|
+
replyMarkup: { inline_keyboard: [] },
|
|
191
|
+
text: `${prompt.text}\n\n${label ?? resolution.outcome}`
|
|
192
|
+
});
|
|
193
|
+
delete channel.state.hitlPrompts?.[resolution.requestId];
|
|
194
|
+
}
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const [callId, message] = entry;
|
|
198
|
+
const text = resolution.outcome === "approved" ? "Confirmed. Executing…" : "Cancelled. Nothing executed.";
|
|
199
|
+
await channel.telegram.editMessageText({
|
|
200
|
+
messageId: message.messageId,
|
|
201
|
+
replyMarkup: { inline_keyboard: [] },
|
|
202
|
+
text: `${message.text}\n\n${text}`
|
|
203
|
+
});
|
|
204
|
+
if (resolution.outcome !== "approved") {
|
|
205
|
+
delete state.confirmationMessages?.[callId];
|
|
206
|
+
confirmations.update((all) => ({
|
|
207
|
+
...all,
|
|
208
|
+
...all[callId] === void 0 ? {} : { [callId]: {
|
|
209
|
+
...all[callId],
|
|
210
|
+
status: "cancelled"
|
|
211
|
+
} }
|
|
212
|
+
}));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
async "action.result"(data, channel) {
|
|
217
|
+
const state = channel.state;
|
|
218
|
+
if (data.result.kind === "tool-result") {
|
|
219
|
+
const question = object(data.result.output).pendingQuestion;
|
|
220
|
+
if (data.result.toolName.endsWith("__questions_ask") && isPendingQuestion(question)) {
|
|
221
|
+
const address = channel.continuation?.token ?? formatAddress({
|
|
222
|
+
chatId: channel.telegram.chatId,
|
|
223
|
+
messageThreadId: String(channel.telegram.messageThreadId ?? ""),
|
|
224
|
+
conversationId: channel.telegram.conversationId ?? ""
|
|
225
|
+
});
|
|
226
|
+
const saved = {
|
|
227
|
+
...question,
|
|
228
|
+
address
|
|
229
|
+
};
|
|
230
|
+
pendingQuestions.update((all) => ({
|
|
231
|
+
...all,
|
|
232
|
+
[questionKey(saved)]: saved
|
|
233
|
+
}));
|
|
234
|
+
state.pendingQuestions = {
|
|
235
|
+
...state.pendingQuestions,
|
|
236
|
+
[questionKey(saved)]: saved
|
|
237
|
+
};
|
|
238
|
+
channel.continuation?.alias(formatAddress({
|
|
239
|
+
chatId: question.chatId,
|
|
240
|
+
messageThreadId: question.messageThreadId,
|
|
241
|
+
conversationId: ""
|
|
242
|
+
}));
|
|
243
|
+
channel.continuation?.alias(questionAddress(saved));
|
|
244
|
+
await channel.telegram.post({
|
|
245
|
+
text: question.question,
|
|
246
|
+
reply_markup: {
|
|
247
|
+
force_reply: true,
|
|
248
|
+
selective: true
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const message = state.confirmationMessages?.[data.result.callId];
|
|
254
|
+
if (message === void 0) return;
|
|
255
|
+
const failed = data.status !== "completed" || data.result.kind === "tool-result" && (data.result.isError === true || object(data.result.output).isError === true || object(object(data.result.output).validation).ok === false);
|
|
256
|
+
const result = data.result.kind === "tool-result" ? JSON.stringify(data.result.output) : "";
|
|
257
|
+
await channel.telegram.editMessageText({
|
|
258
|
+
messageId: message.messageId,
|
|
259
|
+
replyMarkup: { inline_keyboard: [] },
|
|
260
|
+
text: `${message.text}\n\n${failed ? "Failed" : "Completed"}: ${result}`.slice(0, 4096)
|
|
261
|
+
});
|
|
262
|
+
delete state.confirmationMessages?.[data.result.callId];
|
|
263
|
+
},
|
|
264
|
+
"turn.started"(_data, channel, ctx) {
|
|
265
|
+
const memberId = memberIdOf(ctx.session.auth.current);
|
|
266
|
+
const answeredId = ctx.session.auth.current?.attributes.pendingQuestionId;
|
|
267
|
+
if (memberId !== void 0 && typeof answeredId === "string") {
|
|
268
|
+
const key = questionKey({
|
|
269
|
+
memberId,
|
|
270
|
+
chatId: channel.telegram.chatId,
|
|
271
|
+
messageThreadId: String(channel.telegram.messageThreadId ?? "")
|
|
272
|
+
});
|
|
273
|
+
if (pendingQuestions.get()[key]?.id === answeredId) {
|
|
274
|
+
pendingQuestions.update((all) => Object.fromEntries(Object.entries(all).filter(([name]) => name !== key)));
|
|
275
|
+
delete channel.state.pendingQuestions?.[key];
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
export { confirmationCallback, confirmationEvents, handleConfirmationCallback, questionAddress, questionFromHistory };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ChannelSource } from "eve/channels";
|
|
2
|
+
import type { SessionAuthContext } from "eve/context";
|
|
2
3
|
import type { Attachment } from "../attachments.js";
|
|
3
4
|
import type { SessionOperations } from "./sessions.js";
|
|
4
5
|
/** What became of one Telegram file of the message: stored as an attachment, or why not. */
|
|
@@ -32,6 +33,9 @@ export declare class InboundRequest {
|
|
|
32
33
|
sessions: SessionOperations | undefined;
|
|
33
34
|
/** Keeps work the request started, such as a chat report, alive after it answers. */
|
|
34
35
|
waitUntil: ((task: Promise<unknown>) => void) | undefined;
|
|
36
|
+
/** Continues the exact pending question's session, including under thread scope. */
|
|
37
|
+
address: string | undefined;
|
|
38
|
+
respond: ((address: string, requestId: string, decision: "approve" | "cancel", principal: SessionAuthContext) => Promise<unknown>) | undefined;
|
|
35
39
|
constructor(
|
|
36
40
|
/** The agent's description from the compiled manifest, for the routing decision. */
|
|
37
41
|
agentDescription: () => Promise<string>);
|
|
@@ -38,11 +38,14 @@ export interface InboundDependencies {
|
|
|
38
38
|
readonly identities: AgentIdentities;
|
|
39
39
|
readonly bot: BotIdentityLookup;
|
|
40
40
|
readonly decide: RouteDecider;
|
|
41
|
+
/** Full session history for open questions, which can survive compaction. */
|
|
42
|
+
readonly history?: HistoryReader;
|
|
41
43
|
readonly transcribe: Transcriber;
|
|
42
44
|
readonly download: (fileId: string) => Promise<Uint8Array>;
|
|
43
45
|
/** The attachment store; without it, files reach the turn as eve's Telegram file parts. */
|
|
44
46
|
readonly attachments?: AgentAttachments;
|
|
45
47
|
readonly observed: ObservedMessages;
|
|
48
|
+
/** Recent session events for smart routing. */
|
|
46
49
|
readonly readHistory: HistoryReader;
|
|
47
50
|
readonly chats: AgentChats;
|
|
48
51
|
/** The app's `/agent/connect` link for a pairing code. */
|
|
@@ -5,12 +5,14 @@ const __filename = __eveFileURLToPath(import.meta.url);
|
|
|
5
5
|
__eveDirname(__filename);
|
|
6
6
|
__eveCreateRequire(import.meta.url);
|
|
7
7
|
import { telegramPrincipal } from "./principal.mjs";
|
|
8
|
+
import { answerMarker } from "../questions.mjs";
|
|
9
|
+
import { formatAddress, messageAddress, scopedAddress } from "./sessions.mjs";
|
|
10
|
+
import { questionAddress, questionFromHistory } from "./confirmations.mjs";
|
|
8
11
|
import { chatContext } from "./chats.mjs";
|
|
9
12
|
import { attachmentsContext, stageFiles } from "./files.mjs";
|
|
10
13
|
import { observedChatKey, observedContext } from "./observed.mjs";
|
|
11
14
|
import { addressingReason, routeMessage } from "./router.mjs";
|
|
12
15
|
import { routingContext } from "./routing-context.mjs";
|
|
13
|
-
import { messageAddress, scopedAddress } from "./sessions.mjs";
|
|
14
16
|
import { voiceNote, voiceOf, voiceText } from "./voice.mjs";
|
|
15
17
|
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.";
|
|
16
18
|
const UNKNOWN_IN_GROUP = "I don't know you yet. Message me privately to connect your account.";
|
|
@@ -184,13 +186,32 @@ async function handleInboundMessage(input) {
|
|
|
184
186
|
});
|
|
185
187
|
return null;
|
|
186
188
|
}
|
|
187
|
-
|
|
189
|
+
let address = scopedAddress(messageAddress(sent), dependencies.sessions().scope);
|
|
188
190
|
const operations = input.request?.sessions;
|
|
191
|
+
const topicAddress = formatAddress({
|
|
192
|
+
chatId: sent.chat.id,
|
|
193
|
+
messageThreadId: String(sent.messageThreadId ?? ""),
|
|
194
|
+
conversationId: ""
|
|
195
|
+
});
|
|
196
|
+
const questionOwner = {
|
|
197
|
+
memberId: lookup.member.id,
|
|
198
|
+
chatId: sent.chat.id,
|
|
199
|
+
messageThreadId: String(sent.messageThreadId ?? "")
|
|
200
|
+
};
|
|
201
|
+
const memberQuestionAddress = questionAddress(questionOwner);
|
|
202
|
+
const questionSession = await operations?.resolveSession(memberQuestionAddress);
|
|
203
|
+
const owner = questionSession ?? await operations?.resolveSession(address) ?? await operations?.resolveSession(topicAddress);
|
|
204
|
+
const pending = owner === void 0 || dependencies.history === void 0 ? void 0 : questionFromHistory(await dependencies.history(owner.id), questionOwner);
|
|
205
|
+
let answering = pending !== void 0 && pending.memberId === lookup.member.id && pending.chatId === sent.chat.id && pending.messageThreadId === String(sent.messageThreadId ?? "");
|
|
206
|
+
if (answering && pending !== void 0 && input.request !== void 0) {
|
|
207
|
+
address = pending.address || (questionSession === void 0 ? topicAddress : memberQuestionAddress);
|
|
208
|
+
input.request.address = address;
|
|
209
|
+
}
|
|
189
210
|
const voice = voiceOf(sent.raw);
|
|
190
211
|
const caption = sent.text || sent.caption;
|
|
191
212
|
const notes = [];
|
|
192
213
|
let text = caption;
|
|
193
|
-
if (voice !== void 0 && (addressed !== void 0 || dependencies.policy === "smart")) {
|
|
214
|
+
if (voice !== void 0 && (addressed !== void 0 || answering || dependencies.policy === "smart")) {
|
|
194
215
|
const spoken = await voiceText({
|
|
195
216
|
voice,
|
|
196
217
|
model: dependencies.sttModel,
|
|
@@ -209,7 +230,7 @@ async function handleInboundMessage(input) {
|
|
|
209
230
|
chatId: sent.chat.id,
|
|
210
231
|
memberId: lookup.member.id,
|
|
211
232
|
policy: dependencies.policy,
|
|
212
|
-
addressed,
|
|
233
|
+
addressed: addressed ?? (answering ? "pending-question" : void 0),
|
|
213
234
|
decide: dependencies.decide,
|
|
214
235
|
question: async () => {
|
|
215
236
|
const recent = await routingContext({
|
|
@@ -231,13 +252,26 @@ async function handleInboundMessage(input) {
|
|
|
231
252
|
}
|
|
232
253
|
if (voice !== void 0 && input.request !== void 0) input.request.text = text;
|
|
233
254
|
else if (voice !== void 0) notes.push(text);
|
|
255
|
+
if (answering && pending !== void 0 && input.request !== void 0) {
|
|
256
|
+
input.request.text = `${answerMarker(pending.id)}\n${text}`;
|
|
257
|
+
notes.push(`<pending_question>\nTask: ${pending.task}\nQuestion: ${pending.question}\nUse this member's answer to continue the pending task with their current permissions.
|
|
258
|
+
</pending_question>`);
|
|
259
|
+
}
|
|
234
260
|
if (operations !== void 0) {
|
|
235
261
|
const newest = dependencies.observed.list(chat).at(-1);
|
|
236
262
|
if (await dependencies.sessionPolicy.apply({
|
|
237
263
|
address,
|
|
238
264
|
operations,
|
|
239
265
|
...newest === void 0 ? {} : { activeAt: Date.parse(newest.at) || 0 }
|
|
240
|
-
}) === "cleared")
|
|
266
|
+
}) === "cleared") {
|
|
267
|
+
dependencies.observed.clear(chat);
|
|
268
|
+
if (answering && input.request !== void 0) {
|
|
269
|
+
answering = false;
|
|
270
|
+
input.request.text = text;
|
|
271
|
+
const questionNote = notes.findIndex((note) => note.startsWith("<pending_question>"));
|
|
272
|
+
if (questionNote !== -1) notes.splice(questionNote, 1);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
241
275
|
dependencies.sessionPolicy.delivered(address);
|
|
242
276
|
}
|
|
243
277
|
await context.telegram.startTyping();
|
|
@@ -250,11 +284,18 @@ async function handleInboundMessage(input) {
|
|
|
250
284
|
if (input.request !== void 0 && files.size > 0) input.request.files = files;
|
|
251
285
|
await report;
|
|
252
286
|
dependencies.observed.remember(chat, observedMessage(sent, text, lookup.member));
|
|
287
|
+
const principal = telegramPrincipal({
|
|
288
|
+
message: sent,
|
|
289
|
+
member: lookup.member
|
|
290
|
+
});
|
|
253
291
|
return {
|
|
254
|
-
auth:
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
292
|
+
auth: answering && pending !== void 0 ? {
|
|
293
|
+
...principal,
|
|
294
|
+
attributes: {
|
|
295
|
+
...principal.attributes,
|
|
296
|
+
pendingQuestionId: pending.id
|
|
297
|
+
}
|
|
298
|
+
} : principal,
|
|
258
299
|
context: [
|
|
259
300
|
...observedContext(dependencies.observed.take(chat)),
|
|
260
301
|
...group ? chatContext(dependencies.chats.chat(sent.chat.id)) : [],
|
|
@@ -18,6 +18,7 @@ function telegramPrincipal(input) {
|
|
|
18
18
|
chatType: message.chat.type
|
|
19
19
|
};
|
|
20
20
|
if (member.name !== null) attributes.memberName = member.name;
|
|
21
|
+
if (message.messageThreadId !== void 0) attributes.messageThreadId = String(message.messageThreadId);
|
|
21
22
|
if (message.from.username !== void 0) attributes.username = message.from.username;
|
|
22
23
|
return {
|
|
23
24
|
authenticator: TELEGRAM_AUTHENTICATOR,
|
|
@@ -87,7 +87,9 @@ function requestContext(request) {
|
|
|
87
87
|
const lines = [
|
|
88
88
|
"<agent_request>",
|
|
89
89
|
`A request from ${requester.name} (${requester.kind}, user_id ${requester.id}) arrived through the ontology action agent_chat.send_message; the message is the request.`,
|
|
90
|
-
|
|
90
|
+
"This turn already runs in the target chat. Your reply here is the message delivered to this chat. Do not look for or call agent_chat.send_message to deliver it, even if the ontology catalog is empty or that action is unavailable.",
|
|
91
|
+
`Write in this chat${to}, carry out the clear request, ask only for missing or ambiguous information, and handle the replies here.`,
|
|
92
|
+
"If the addressed person has a linked Telegram username, mention them with @username; otherwise address them by name without a tag. Never invent a username."
|
|
91
93
|
];
|
|
92
94
|
if (requester.kind === "service" || !requester.actAs) lines.push(`${requester.kind === "service" ? "The requester is a service, not a member" : "The requester has not paired a Telegram account with the agent"}, so this turn runs without a member and the ontology is not available in it. Each member who replies acts with their own permissions.`);
|
|
93
95
|
lines.push("</agent_request>");
|
|
@@ -8,7 +8,7 @@ import type { ObservedMessage, RecentMessage } 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
|
+
export type AddressingReason = "private" | "mention" | "reply-to-bot" | "command" | "pending-question";
|
|
12
12
|
/** The bot, as Telegram's `getMe` describes it. Unknown when `getMe` failed. */
|
|
13
13
|
export interface BotIdentity {
|
|
14
14
|
readonly id: string;
|
|
@@ -23,8 +23,8 @@ async function routingContext(input) {
|
|
|
23
23
|
resetAt = Date.parse(event.meta.at);
|
|
24
24
|
}
|
|
25
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({
|
|
26
|
+
const data = typeof event.data === "object" && event.data !== null ? event.data : void 0;
|
|
27
|
+
if (event.type === "message.completed" && data !== void 0 && typeof data.message === "string" && typeof data.finishReason === "string" && data.finishReason !== "tool-calls" && data.message.trim() !== "" && !isSilentReply(data.message) && age >= 0 && age <= 18e5) recovered.push(agentMessage({
|
|
28
28
|
text: data.message,
|
|
29
29
|
at: event.meta.at
|
|
30
30
|
}));
|
|
@@ -37,19 +37,16 @@ export interface SessionHistory {
|
|
|
37
37
|
/** Turns completed since `resetAt`; a compaction is worth nothing before the first. */
|
|
38
38
|
readonly turnsSinceReset: number;
|
|
39
39
|
}
|
|
40
|
-
/** One recorded event
|
|
40
|
+
/** One recorded event used by session policy, routing context and interaction projections. */
|
|
41
41
|
export interface RecordedEvent {
|
|
42
42
|
readonly type: string;
|
|
43
43
|
readonly meta: {
|
|
44
44
|
readonly at: string;
|
|
45
45
|
};
|
|
46
|
-
/**
|
|
47
|
-
readonly data?:
|
|
48
|
-
readonly message: string;
|
|
49
|
-
readonly finishReason: string;
|
|
50
|
-
};
|
|
46
|
+
/** Consumers validate the payload for the event type they project. */
|
|
47
|
+
readonly data?: unknown;
|
|
51
48
|
}
|
|
52
|
-
/**
|
|
49
|
+
/** Session events, oldest first; readers choose a tail or full stream for their consumers. */
|
|
53
50
|
export type HistoryReader = (sessionId: string) => Promise<ReadonlyArray<RecordedEvent>>;
|
|
54
51
|
/** How many events of the tail the reader fetches: enough for a day of a busy group. */
|
|
55
52
|
export declare const HISTORY_TAIL = 400;
|
|
@@ -4,9 +4,12 @@ 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 { actionApproval } from "../lib/confirmations.mjs";
|
|
8
|
+
import { actionToolName, ontologyToolLabels } from "../lib/ontology-tools.mjs";
|
|
7
9
|
import { attachmentForModel, attachmentOwner, optionalObject, optionalString, stringArgument, toolAttachments } from "../lib/attachment-tools.mjs";
|
|
8
10
|
import { defineTool } from "eve/tools";
|
|
9
11
|
var attachments_promote_default = defineTool({
|
|
12
|
+
approval: actionApproval({ labels: (memberId, _tool, input) => ontologyToolLabels()(memberId, actionToolName(stringArgument(input, "entity"), stringArgument(input, "action"))) }),
|
|
10
13
|
description: "Keep a file the member sent by storing it in a record through an ontology action that takes a file, such as document.upload. Give the entity, the action and the action's input without the file field: the platform fills it with the attachment, and fills sha256 when the action takes one. Runs with the member's permissions. The same file promoted again through the same action returns the earlier result instead of a duplicate.",
|
|
11
14
|
inputSchema: {
|
|
12
15
|
type: "object",
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
declare const _default: import("eve/tools").ToolDefinition<Record<string, unknown>, {
|
|
2
|
+
pendingQuestion: {
|
|
3
|
+
id: string;
|
|
4
|
+
question: string;
|
|
5
|
+
task: string;
|
|
6
|
+
memberId: string;
|
|
7
|
+
chatId: string;
|
|
8
|
+
messageThreadId: string;
|
|
9
|
+
address: string;
|
|
10
|
+
};
|
|
11
|
+
}>;
|
|
12
|
+
export default _default;
|
|
@@ -0,0 +1,50 @@
|
|
|
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 "../lib/telegram/principal.mjs";
|
|
8
|
+
import { pendingQuestions, questionKey } from "../lib/questions.mjs";
|
|
9
|
+
import { stringArgument } from "../lib/attachment-tools.mjs";
|
|
10
|
+
import { defineTool } from "eve/tools";
|
|
11
|
+
var questions_ask_default = defineTool({
|
|
12
|
+
availableInSubagents: false,
|
|
13
|
+
description: "Ask for missing or ambiguous information needed to continue the member's task. Saves the task and question in the session, sends the question, and waits for their answer. Never use this for approval: destructive or irreversible actions have Confirm / Cancel buttons.",
|
|
14
|
+
inputSchema: {
|
|
15
|
+
type: "object",
|
|
16
|
+
properties: {
|
|
17
|
+
question: {
|
|
18
|
+
type: "string",
|
|
19
|
+
description: "The one question to send to the member."
|
|
20
|
+
},
|
|
21
|
+
task: {
|
|
22
|
+
type: "string",
|
|
23
|
+
description: "The pending task, including known targets and supplied values."
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
required: ["question", "task"],
|
|
27
|
+
additionalProperties: false
|
|
28
|
+
},
|
|
29
|
+
endsTurn: true,
|
|
30
|
+
execute(input, ctx) {
|
|
31
|
+
const memberId = memberIdOf(ctx.session.auth.current);
|
|
32
|
+
const chatId = ctx.session.auth.current?.attributes.chatId;
|
|
33
|
+
if (memberId === void 0 || typeof chatId !== "string") throw new Error("A member in a chat is required.");
|
|
34
|
+
const question = {
|
|
35
|
+
id: ctx.callId,
|
|
36
|
+
question: stringArgument(input, "question"),
|
|
37
|
+
task: stringArgument(input, "task"),
|
|
38
|
+
memberId,
|
|
39
|
+
chatId,
|
|
40
|
+
messageThreadId: String(ctx.session.auth.current?.attributes.messageThreadId ?? ""),
|
|
41
|
+
address: ""
|
|
42
|
+
};
|
|
43
|
+
pendingQuestions.update((all) => ({
|
|
44
|
+
...all,
|
|
45
|
+
[questionKey(question)]: question
|
|
46
|
+
}));
|
|
47
|
+
return { pendingQuestion: question };
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
export { questions_ask_default as default };
|
package/dist/tools/index.d.ts
CHANGED
|
@@ -5,3 +5,4 @@ export { default as attachments_list } from "../extension/tools/attachments_list
|
|
|
5
5
|
export { default as attachments_promote } from "../extension/tools/attachments_promote.js";
|
|
6
6
|
export { default as attachments_read } from "../extension/tools/attachments_read.js";
|
|
7
7
|
export { default as attachments_table } from "../extension/tools/attachments_table.js";
|
|
8
|
+
export { default as questions_ask } from "../extension/tools/questions_ask.js";
|
package/dist/tools/index.mjs
CHANGED
|
@@ -9,4 +9,5 @@ import attachments_list_default from "../extension/tools/attachments_list.mjs";
|
|
|
9
9
|
import attachments_promote_default from "../extension/tools/attachments_promote.mjs";
|
|
10
10
|
import attachments_read_default from "../extension/tools/attachments_read.mjs";
|
|
11
11
|
import attachments_table_default from "../extension/tools/attachments_table.mjs";
|
|
12
|
-
|
|
12
|
+
import questions_ask_default from "../extension/tools/questions_ask.mjs";
|
|
13
|
+
export { attachments_discard_default as attachments_discard, attachments_list_default as attachments_list, attachments_promote_default as attachments_promote, attachments_read_default as attachments_read, attachments_table_default as attachments_table, questions_ask_default as questions_ask };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@segmentstream/agent",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.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",
|