271 lines
9.1 KiB
TypeScript
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,
|
|
});
|
|
},
|
|
};
|
|
}
|