328 lines
10 KiB
TypeScript
328 lines
10 KiB
TypeScript
import type { MoltbotConfig } from "../../config/config.js";
|
|
import { callGateway } from "../../gateway/call.js";
|
|
import { sanitizeUserFacingText } from "../pi-embedded-helpers.js";
|
|
import {
|
|
stripDowngradedToolCallText,
|
|
stripMinimaxToolCallXml,
|
|
stripThinkingTagsFromText,
|
|
} from "../pi-embedded-utils.js";
|
|
import { isAcpSessionKey, normalizeMainKey } from "../../routing/session-key.js";
|
|
|
|
export type SessionKind = "main" | "group" | "cron" | "hook" | "node" | "other";
|
|
|
|
export type SessionListDeliveryContext = {
|
|
channel?: string;
|
|
to?: string;
|
|
accountId?: string;
|
|
};
|
|
|
|
export type SessionListRow = {
|
|
key: string;
|
|
kind: SessionKind;
|
|
channel: string;
|
|
label?: string;
|
|
displayName?: string;
|
|
deliveryContext?: SessionListDeliveryContext;
|
|
updatedAt?: number | null;
|
|
sessionId?: string;
|
|
model?: string;
|
|
contextTokens?: number | null;
|
|
totalTokens?: number | null;
|
|
thinkingLevel?: string;
|
|
verboseLevel?: string;
|
|
systemSent?: boolean;
|
|
abortedLastRun?: boolean;
|
|
sendPolicy?: string;
|
|
lastChannel?: string;
|
|
lastTo?: string;
|
|
lastAccountId?: string;
|
|
transcriptPath?: string;
|
|
messages?: unknown[];
|
|
};
|
|
|
|
function normalizeKey(value?: string) {
|
|
const trimmed = value?.trim();
|
|
return trimmed ? trimmed : undefined;
|
|
}
|
|
|
|
export function resolveMainSessionAlias(cfg: MoltbotConfig) {
|
|
const mainKey = normalizeMainKey(cfg.session?.mainKey);
|
|
const scope = cfg.session?.scope ?? "per-sender";
|
|
const alias = scope === "global" ? "global" : mainKey;
|
|
return { mainKey, alias, scope };
|
|
}
|
|
|
|
export function resolveDisplaySessionKey(params: { key: string; alias: string; mainKey: string }) {
|
|
if (params.key === params.alias) return "main";
|
|
if (params.key === params.mainKey) return "main";
|
|
return params.key;
|
|
}
|
|
|
|
export function resolveInternalSessionKey(params: { key: string; alias: string; mainKey: string }) {
|
|
if (params.key === "main") return params.alias;
|
|
return params.key;
|
|
}
|
|
|
|
export type AgentToAgentPolicy = {
|
|
enabled: boolean;
|
|
matchesAllow: (agentId: string) => boolean;
|
|
isAllowed: (requesterAgentId: string, targetAgentId: string) => boolean;
|
|
};
|
|
|
|
export function createAgentToAgentPolicy(cfg: MoltbotConfig): AgentToAgentPolicy {
|
|
const routingA2A = cfg.tools?.agentToAgent;
|
|
const enabled = routingA2A?.enabled === true;
|
|
const allowPatterns = Array.isArray(routingA2A?.allow) ? routingA2A.allow : [];
|
|
const matchesAllow = (agentId: string) => {
|
|
if (allowPatterns.length === 0) return true;
|
|
return allowPatterns.some((pattern) => {
|
|
const raw = String(pattern ?? "").trim();
|
|
if (!raw) return false;
|
|
if (raw === "*") return true;
|
|
if (!raw.includes("*")) return raw === agentId;
|
|
const escaped = raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
const re = new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`, "i");
|
|
return re.test(agentId);
|
|
});
|
|
};
|
|
const isAllowed = (requesterAgentId: string, targetAgentId: string) => {
|
|
if (requesterAgentId === targetAgentId) return true;
|
|
if (!enabled) return false;
|
|
return matchesAllow(requesterAgentId) && matchesAllow(targetAgentId);
|
|
};
|
|
return { enabled, matchesAllow, isAllowed };
|
|
}
|
|
|
|
const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
|
|
export function looksLikeSessionId(value: string): boolean {
|
|
return SESSION_ID_RE.test(value.trim());
|
|
}
|
|
|
|
export function looksLikeSessionKey(value: string): boolean {
|
|
const raw = value.trim();
|
|
if (!raw) return false;
|
|
// These are canonical key shapes that should never be treated as sessionIds.
|
|
if (raw === "main" || raw === "global" || raw === "unknown") return true;
|
|
if (isAcpSessionKey(raw)) return true;
|
|
if (raw.startsWith("agent:")) return true;
|
|
if (raw.startsWith("cron:") || raw.startsWith("hook:")) return true;
|
|
if (raw.startsWith("node-") || raw.startsWith("node:")) return true;
|
|
if (raw.includes(":group:") || raw.includes(":channel:")) return true;
|
|
return false;
|
|
}
|
|
|
|
export function shouldResolveSessionIdInput(value: string): boolean {
|
|
// Treat anything that doesn't look like a well-formed key as a sessionId candidate.
|
|
return looksLikeSessionId(value) || !looksLikeSessionKey(value);
|
|
}
|
|
|
|
export type SessionReferenceResolution =
|
|
| {
|
|
ok: true;
|
|
key: string;
|
|
displayKey: string;
|
|
resolvedViaSessionId: boolean;
|
|
}
|
|
| { ok: false; status: "error" | "forbidden"; error: string };
|
|
|
|
async function resolveSessionKeyFromSessionId(params: {
|
|
sessionId: string;
|
|
alias: string;
|
|
mainKey: string;
|
|
requesterInternalKey?: string;
|
|
restrictToSpawned: boolean;
|
|
}): Promise<SessionReferenceResolution> {
|
|
try {
|
|
// Resolve via gateway so we respect store routing and visibility rules.
|
|
const result = (await callGateway({
|
|
method: "sessions.resolve",
|
|
params: {
|
|
sessionId: params.sessionId,
|
|
spawnedBy: params.restrictToSpawned ? params.requesterInternalKey : undefined,
|
|
includeGlobal: !params.restrictToSpawned,
|
|
includeUnknown: !params.restrictToSpawned,
|
|
},
|
|
})) as { key?: unknown };
|
|
const key = typeof result?.key === "string" ? result.key.trim() : "";
|
|
if (!key) {
|
|
throw new Error(
|
|
`Session not found: ${params.sessionId} (use the full sessionKey from sessions_list)`,
|
|
);
|
|
}
|
|
return {
|
|
ok: true,
|
|
key,
|
|
displayKey: resolveDisplaySessionKey({
|
|
key,
|
|
alias: params.alias,
|
|
mainKey: params.mainKey,
|
|
}),
|
|
resolvedViaSessionId: true,
|
|
};
|
|
} catch (err) {
|
|
if (params.restrictToSpawned) {
|
|
return {
|
|
ok: false,
|
|
status: "forbidden",
|
|
error: `Session not visible from this sandboxed agent session: ${params.sessionId}`,
|
|
};
|
|
}
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
return {
|
|
ok: false,
|
|
status: "error",
|
|
error:
|
|
message ||
|
|
`Session not found: ${params.sessionId} (use the full sessionKey from sessions_list)`,
|
|
};
|
|
}
|
|
}
|
|
|
|
async function resolveSessionKeyFromKey(params: {
|
|
key: string;
|
|
alias: string;
|
|
mainKey: string;
|
|
requesterInternalKey?: string;
|
|
restrictToSpawned: boolean;
|
|
}): Promise<SessionReferenceResolution | null> {
|
|
try {
|
|
// Try key-based resolution first so non-standard keys keep working.
|
|
const result = (await callGateway({
|
|
method: "sessions.resolve",
|
|
params: {
|
|
key: params.key,
|
|
spawnedBy: params.restrictToSpawned ? params.requesterInternalKey : undefined,
|
|
},
|
|
})) as { key?: unknown };
|
|
const key = typeof result?.key === "string" ? result.key.trim() : "";
|
|
if (!key) return null;
|
|
return {
|
|
ok: true,
|
|
key,
|
|
displayKey: resolveDisplaySessionKey({
|
|
key,
|
|
alias: params.alias,
|
|
mainKey: params.mainKey,
|
|
}),
|
|
resolvedViaSessionId: false,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function resolveSessionReference(params: {
|
|
sessionKey: string;
|
|
alias: string;
|
|
mainKey: string;
|
|
requesterInternalKey?: string;
|
|
restrictToSpawned: boolean;
|
|
}): Promise<SessionReferenceResolution> {
|
|
const raw = params.sessionKey.trim();
|
|
if (shouldResolveSessionIdInput(raw)) {
|
|
// Prefer key resolution to avoid misclassifying custom keys as sessionIds.
|
|
const resolvedByKey = await resolveSessionKeyFromKey({
|
|
key: raw,
|
|
alias: params.alias,
|
|
mainKey: params.mainKey,
|
|
requesterInternalKey: params.requesterInternalKey,
|
|
restrictToSpawned: params.restrictToSpawned,
|
|
});
|
|
if (resolvedByKey) return resolvedByKey;
|
|
return await resolveSessionKeyFromSessionId({
|
|
sessionId: raw,
|
|
alias: params.alias,
|
|
mainKey: params.mainKey,
|
|
requesterInternalKey: params.requesterInternalKey,
|
|
restrictToSpawned: params.restrictToSpawned,
|
|
});
|
|
}
|
|
|
|
const resolvedKey = resolveInternalSessionKey({
|
|
key: raw,
|
|
alias: params.alias,
|
|
mainKey: params.mainKey,
|
|
});
|
|
const displayKey = resolveDisplaySessionKey({
|
|
key: resolvedKey,
|
|
alias: params.alias,
|
|
mainKey: params.mainKey,
|
|
});
|
|
return { ok: true, key: resolvedKey, displayKey, resolvedViaSessionId: false };
|
|
}
|
|
|
|
export function classifySessionKind(params: {
|
|
key: string;
|
|
gatewayKind?: string | null;
|
|
alias: string;
|
|
mainKey: string;
|
|
}): SessionKind {
|
|
const key = params.key;
|
|
if (key === params.alias || key === params.mainKey) return "main";
|
|
if (key.startsWith("cron:")) return "cron";
|
|
if (key.startsWith("hook:")) return "hook";
|
|
if (key.startsWith("node-") || key.startsWith("node:")) return "node";
|
|
if (params.gatewayKind === "group") return "group";
|
|
if (key.includes(":group:") || key.includes(":channel:")) {
|
|
return "group";
|
|
}
|
|
return "other";
|
|
}
|
|
|
|
export function deriveChannel(params: {
|
|
key: string;
|
|
kind: SessionKind;
|
|
channel?: string | null;
|
|
lastChannel?: string | null;
|
|
}): string {
|
|
if (params.kind === "cron" || params.kind === "hook" || params.kind === "node") return "internal";
|
|
const channel = normalizeKey(params.channel ?? undefined);
|
|
if (channel) return channel;
|
|
const lastChannel = normalizeKey(params.lastChannel ?? undefined);
|
|
if (lastChannel) return lastChannel;
|
|
const parts = params.key.split(":").filter(Boolean);
|
|
if (parts.length >= 3 && (parts[1] === "group" || parts[1] === "channel")) {
|
|
return parts[0];
|
|
}
|
|
return "unknown";
|
|
}
|
|
|
|
export function stripToolMessages(messages: unknown[]): unknown[] {
|
|
return messages.filter((msg) => {
|
|
if (!msg || typeof msg !== "object") return true;
|
|
const role = (msg as { role?: unknown }).role;
|
|
return role !== "toolResult";
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Sanitize text content to strip tool call markers and thinking tags.
|
|
* This ensures user-facing text doesn't leak internal tool representations.
|
|
*/
|
|
export function sanitizeTextContent(text: string): string {
|
|
if (!text) return text;
|
|
return stripThinkingTagsFromText(stripDowngradedToolCallText(stripMinimaxToolCallXml(text)));
|
|
}
|
|
|
|
export function extractAssistantText(message: unknown): string | undefined {
|
|
if (!message || typeof message !== "object") return undefined;
|
|
if ((message as { role?: unknown }).role !== "assistant") return undefined;
|
|
const content = (message as { content?: unknown }).content;
|
|
if (!Array.isArray(content)) return undefined;
|
|
const chunks: string[] = [];
|
|
for (const block of content) {
|
|
if (!block || typeof block !== "object") continue;
|
|
if ((block as { type?: unknown }).type !== "text") continue;
|
|
const text = (block as { text?: unknown }).text;
|
|
if (typeof text === "string") {
|
|
const sanitized = sanitizeTextContent(text);
|
|
if (sanitized.trim()) {
|
|
chunks.push(sanitized);
|
|
}
|
|
}
|
|
}
|
|
const joined = chunks.join("").trim();
|
|
return joined ? sanitizeUserFacingText(joined) : undefined;
|
|
}
|