Spaces:
Paused
Paused
| import type { OpenClawConfig } from "../../config/config.js"; | |
| import { callGateway } from "../../gateway/call.js"; | |
| import { isAcpSessionKey, normalizeMainKey } from "../../routing/session-key.js"; | |
| import { sanitizeUserFacingText } from "../pi-embedded-helpers.js"; | |
| import { | |
| stripDowngradedToolCallText, | |
| stripMinimaxToolCallXml, | |
| stripThinkingTagsFromText, | |
| } from "../pi-embedded-utils.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: OpenClawConfig) { | |
| 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: OpenClawConfig): 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<{ key?: string }>({ | |
| method: "sessions.resolve", | |
| params: { | |
| sessionId: params.sessionId, | |
| spawnedBy: params.restrictToSpawned ? params.requesterInternalKey : undefined, | |
| includeGlobal: !params.restrictToSpawned, | |
| includeUnknown: !params.restrictToSpawned, | |
| }, | |
| }); | |
| 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<{ key?: string }>({ | |
| method: "sessions.resolve", | |
| params: { | |
| key: params.key, | |
| spawnedBy: params.restrictToSpawned ? params.requesterInternalKey : undefined, | |
| }, | |
| }); | |
| 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(); | |
| const stopReason = (message as { stopReason?: unknown }).stopReason; | |
| const errorMessage = (message as { errorMessage?: unknown }).errorMessage; | |
| const errorContext = | |
| stopReason === "error" || (typeof errorMessage === "string" && Boolean(errorMessage.trim())); | |
| return joined ? sanitizeUserFacingText(joined, { errorContext }) : undefined; | |
| } | |