Files
openclaw/src/agents/tools/sessions-history-tool.ts
2026-03-02 19:57:33 +00:00

271 lines
9.1 KiB
TypeScript

import { Type } from "@sinclair/typebox";
import { loadConfig } from "../../config/config.js";
import { callGateway } from "../../gateway/call.js";
import { capArrayByJsonBytes } from "../../gateway/session-utils.fs.js";
import { jsonUtf8Bytes } from "../../infra/json-utf8-bytes.js";
import { redactSensitiveText } from "../../logging/redact.js";
import { truncateUtf16Safe } from "../../utils.js";
import type { AnyAgentTool } from "./common.js";
import { jsonResult, readStringParam } from "./common.js";
import {
createSessionVisibilityGuard,
createAgentToAgentPolicy,
resolveEffectiveSessionToolsVisibility,
resolveSessionReference,
resolveSandboxedSessionToolContext,
resolveVisibleSessionReference,
stripToolMessages,
} from "./sessions-helpers.js";
const SessionsHistoryToolSchema = Type.Object({
sessionKey: Type.String(),
limit: Type.Optional(Type.Number({ minimum: 1 })),
includeTools: Type.Optional(Type.Boolean()),
});
const SESSIONS_HISTORY_MAX_BYTES = 80 * 1024;
const SESSIONS_HISTORY_TEXT_MAX_CHARS = 4000;
// sandbox policy handling is shared with sessions-list-tool via sessions-helpers.ts
function truncateHistoryText(text: string): {
text: string;
truncated: boolean;
redacted: boolean;
} {
// Redact credentials, API keys, tokens before returning session history.
// Prevents sensitive data leakage via sessions_history tool (OC-07).
const sanitized = redactSensitiveText(text);
const redacted = sanitized !== text;
if (sanitized.length <= SESSIONS_HISTORY_TEXT_MAX_CHARS) {
return { text: sanitized, truncated: false, redacted };
}
const cut = truncateUtf16Safe(sanitized, SESSIONS_HISTORY_TEXT_MAX_CHARS);
return { text: `${cut}\n…(truncated)…`, truncated: true, redacted };
}
function sanitizeHistoryContentBlock(block: unknown): {
block: unknown;
truncated: boolean;
redacted: boolean;
} {
if (!block || typeof block !== "object") {
return { block, truncated: false, redacted: false };
}
const entry = { ...(block as Record<string, unknown>) };
let truncated = false;
let redacted = false;
const type = typeof entry.type === "string" ? entry.type : "";
if (typeof entry.text === "string") {
const res = truncateHistoryText(entry.text);
entry.text = res.text;
truncated ||= res.truncated;
redacted ||= res.redacted;
}
if (type === "thinking") {
if (typeof entry.thinking === "string") {
const res = truncateHistoryText(entry.thinking);
entry.thinking = res.text;
truncated ||= res.truncated;
redacted ||= res.redacted;
}
// The encrypted signature can be extremely large and is not useful for history recall.
if ("thinkingSignature" in entry) {
delete entry.thinkingSignature;
truncated = true;
}
}
if (typeof entry.partialJson === "string") {
const res = truncateHistoryText(entry.partialJson);
entry.partialJson = res.text;
truncated ||= res.truncated;
redacted ||= res.redacted;
}
if (type === "image") {
const data = typeof entry.data === "string" ? entry.data : undefined;
const bytes = data ? data.length : undefined;
if ("data" in entry) {
delete entry.data;
truncated = true;
}
entry.omitted = true;
if (bytes !== undefined) {
entry.bytes = bytes;
}
}
return { block: entry, truncated, redacted };
}
function sanitizeHistoryMessage(message: unknown): {
message: unknown;
truncated: boolean;
redacted: boolean;
} {
if (!message || typeof message !== "object") {
return { message, truncated: false, redacted: false };
}
const entry = { ...(message as Record<string, unknown>) };
let truncated = false;
let redacted = false;
// Tool result details often contain very large nested payloads.
if ("details" in entry) {
delete entry.details;
truncated = true;
}
if ("usage" in entry) {
delete entry.usage;
truncated = true;
}
if ("cost" in entry) {
delete entry.cost;
truncated = true;
}
if (typeof entry.content === "string") {
const res = truncateHistoryText(entry.content);
entry.content = res.text;
truncated ||= res.truncated;
redacted ||= res.redacted;
} else if (Array.isArray(entry.content)) {
const updated = entry.content.map((block) => sanitizeHistoryContentBlock(block));
entry.content = updated.map((item) => item.block);
truncated ||= updated.some((item) => item.truncated);
redacted ||= updated.some((item) => item.redacted);
}
if (typeof entry.text === "string") {
const res = truncateHistoryText(entry.text);
entry.text = res.text;
truncated ||= res.truncated;
redacted ||= res.redacted;
}
return { message: entry, truncated, redacted };
}
function enforceSessionsHistoryHardCap(params: {
items: unknown[];
bytes: number;
maxBytes: number;
}): { items: unknown[]; bytes: number; hardCapped: boolean } {
if (params.bytes <= params.maxBytes) {
return { items: params.items, bytes: params.bytes, hardCapped: false };
}
const last = params.items.at(-1);
const lastOnly = last ? [last] : [];
const lastBytes = jsonUtf8Bytes(lastOnly);
if (lastBytes <= params.maxBytes) {
return { items: lastOnly, bytes: lastBytes, hardCapped: true };
}
const placeholder = [
{
role: "assistant",
content: "[sessions_history omitted: message too large]",
},
];
return { items: placeholder, bytes: jsonUtf8Bytes(placeholder), hardCapped: true };
}
export function createSessionsHistoryTool(opts?: {
agentSessionKey?: string;
sandboxed?: boolean;
}): AnyAgentTool {
return {
label: "Session History",
name: "sessions_history",
description: "Fetch message history for a session.",
parameters: SessionsHistoryToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const sessionKeyParam = readStringParam(params, "sessionKey", {
required: true,
});
const cfg = loadConfig();
const { mainKey, alias, effectiveRequesterKey, restrictToSpawned } =
resolveSandboxedSessionToolContext({
cfg,
agentSessionKey: opts?.agentSessionKey,
sandboxed: opts?.sandboxed,
});
const resolvedSession = await resolveSessionReference({
sessionKey: sessionKeyParam,
alias,
mainKey,
requesterInternalKey: effectiveRequesterKey,
restrictToSpawned,
});
if (!resolvedSession.ok) {
return jsonResult({ status: resolvedSession.status, error: resolvedSession.error });
}
const visibleSession = await resolveVisibleSessionReference({
resolvedSession,
requesterSessionKey: effectiveRequesterKey,
restrictToSpawned,
visibilitySessionKey: sessionKeyParam,
});
if (!visibleSession.ok) {
return jsonResult({
status: visibleSession.status,
error: visibleSession.error,
});
}
// From here on, use the canonical key (sessionId inputs already resolved).
const resolvedKey = visibleSession.key;
const displayKey = visibleSession.displayKey;
const a2aPolicy = createAgentToAgentPolicy(cfg);
const visibility = resolveEffectiveSessionToolsVisibility({
cfg,
sandboxed: opts?.sandboxed === true,
});
const visibilityGuard = await createSessionVisibilityGuard({
action: "history",
requesterSessionKey: effectiveRequesterKey,
visibility,
a2aPolicy,
});
const access = visibilityGuard.check(resolvedKey);
if (!access.allowed) {
return jsonResult({
status: access.status,
error: access.error,
});
}
const limit =
typeof params.limit === "number" && Number.isFinite(params.limit)
? Math.max(1, Math.floor(params.limit))
: undefined;
const includeTools = Boolean(params.includeTools);
const result = await callGateway<{ messages: Array<unknown> }>({
method: "chat.history",
params: { sessionKey: resolvedKey, limit },
});
const rawMessages = Array.isArray(result?.messages) ? result.messages : [];
const selectedMessages = includeTools ? rawMessages : stripToolMessages(rawMessages);
const sanitizedMessages = selectedMessages.map((message) => sanitizeHistoryMessage(message));
const contentTruncated = sanitizedMessages.some((entry) => entry.truncated);
const contentRedacted = sanitizedMessages.some((entry) => entry.redacted);
const cappedMessages = capArrayByJsonBytes(
sanitizedMessages.map((entry) => entry.message),
SESSIONS_HISTORY_MAX_BYTES,
);
const droppedMessages = cappedMessages.items.length < selectedMessages.length;
const hardened = enforceSessionsHistoryHardCap({
items: cappedMessages.items,
bytes: cappedMessages.bytes,
maxBytes: SESSIONS_HISTORY_MAX_BYTES,
});
return jsonResult({
sessionKey: displayKey,
messages: hardened.items,
truncated: droppedMessages || contentTruncated || hardened.hardCapped,
droppedMessages: droppedMessages || hardened.hardCapped,
contentTruncated,
contentRedacted,
bytes: hardened.bytes,
});
},
};
}