fix(auto-reply): guard context report tool entries

This commit is contained in:
Vincent Koc
2026-06-03 09:15:40 +02:00
parent b6cee3fc35
commit eb8ae55da4
4 changed files with 127 additions and 7 deletions
@@ -16,6 +16,7 @@ function makeParams(
sessionKey?: string;
agentId?: string;
currentTurn?: NonNullable<SessionEntry["systemPromptReport"]>["currentTurn"];
toolEntries?: NonNullable<SessionEntry["systemPromptReport"]>["tools"]["entries"];
},
): HandleCommandsParams {
return {
@@ -67,7 +68,9 @@ function makeParams(
tools: {
listChars: 10,
schemaChars: 20,
entries: [{ name: "read", summaryChars: 10, schemaChars: 20, propertiesCount: 1 }],
entries: options?.toolEntries ?? [
{ name: "read", summaryChars: 10, schemaChars: 20, propertiesCount: 1 },
],
},
},
},
@@ -159,6 +162,30 @@ describe("buildContextReply", () => {
expect(result.text).not.toContain("~645 tok");
});
it("omits unreadable tool report entries from detail output", async () => {
const malformedTool = {
get name(): string {
throw new Error("fuzzed unreadable report tool name");
},
summaryChars: 10,
schemaChars: 20,
propertiesCount: 1,
};
const result = await buildContextReply(
makeParams("/context detail", false, {
toolEntries: [
{ name: "read", summaryChars: 10, schemaChars: 20, propertiesCount: 1 },
malformedTool,
],
}),
);
expect(result.text).toContain("Tools: read");
expect(result.text).toContain("- read: 20 chars (~5 tok)");
expect(result.text).not.toContain("fuzzed unreadable");
});
it("prefers the target session entry from sessionStore for cached context stats", async () => {
const params = makeParams("/context detail", false, {
contextTokens: 8_192,
@@ -217,6 +244,38 @@ describe("buildContextReply", () => {
}
});
it("omits unreadable tool report entries from context maps", async () => {
const malformedTool = {
get name(): string {
throw new Error("fuzzed unreadable report tool name");
},
summaryChars: 10,
schemaChars: 20,
propertiesCount: 1,
};
const result = await buildContextReply(
makeParams("/context map", false, {
contextTokens: 8_192,
totalTokens: 900,
toolEntries: [
{ name: "read", summaryChars: 10, schemaChars: 20, propertiesCount: 1 },
malformedTool,
],
}),
);
if (!result.mediaUrl) {
throw new Error("missing context map media path");
}
try {
const png = await readFile(result.mediaUrl);
expect(result.text).toContain("Context treemap");
expect(result.text).toContain("Tracked: 10,520 chars");
expect(png.subarray(0, 8)).toEqual(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
} finally {
await unlink(result.mediaUrl);
}
});
it("counts room events as event context in context maps", async () => {
const result = await buildContextReply(
makeParams("/context map", false, {
@@ -13,6 +13,7 @@ import {
import { estimateTokensFromChars } from "../../utils/cjk-chars.js";
import type { ReplyPayload } from "../types.js";
import type { HandleCommandsParams } from "./commands-types.js";
import { readContextReportToolEntries } from "./context-report-tools.js";
import { renderContextTreemapPng } from "./context-treemap.js";
function formatInt(n: number): string {
@@ -178,7 +179,8 @@ export async function buildContextReply(params: HandleCommandsParams): Promise<R
const toolListLine = `Tool list (system prompt text): ${formatCharsAndTokens(report.tools.listChars)}`;
const skillNameSet = new Set(report.skills.entries.map((s) => s.name));
const skillNames = Array.from(skillNameSet);
const toolNames = report.tools.entries.map((t) => t.name);
const toolEntries = readContextReportToolEntries(report.tools.entries);
const toolNames = toolEntries.map((t) => t.name);
const formatNameList = (names: string[], cap: number) =>
names.length <= cap
? names.join(", ")
@@ -267,14 +269,14 @@ export async function buildContextReply(params: HandleCommandsParams): Promise<R
30,
);
const perToolSchema = formatListTop(
report.tools.entries.map((t) => ({ name: t.name, value: t.schemaChars })),
toolEntries.map((t) => ({ name: t.name, value: t.schemaChars })),
30,
);
const perToolSummary = formatListTop(
report.tools.entries.map((t) => ({ name: t.name, value: t.summaryChars })),
toolEntries.map((t) => ({ name: t.name, value: t.summaryChars })),
30,
);
const toolPropsLines = report.tools.entries
const toolPropsLines = toolEntries
.filter((t) => t.propertiesCount != null)
.toSorted((a, b) => (b.propertiesCount ?? 0) - (a.propertiesCount ?? 0))
.slice(0, 30)
@@ -0,0 +1,58 @@
import type { SessionSystemPromptReport } from "../../config/sessions/types.js";
type SessionToolReportEntry = SessionSystemPromptReport["tools"]["entries"][number];
export type ReadableContextReportToolEntry = {
readonly name: string;
readonly summaryChars: number;
readonly schemaChars: number;
readonly propertiesCount?: number | null;
};
function readNonNegativeNumber(read: () => number | null | undefined): number {
try {
const value = read();
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
} catch {
return 0;
}
}
function readOptionalCount(read: () => number | null | undefined): number | null | undefined {
try {
const value = read();
if (value == null) {
return value;
}
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
} catch {
return undefined;
}
}
function readToolName(entry: SessionToolReportEntry): string | undefined {
try {
return typeof entry.name === "string" && entry.name ? entry.name : undefined;
} catch {
return undefined;
}
}
export function readContextReportToolEntries(
entries: readonly SessionToolReportEntry[],
): ReadableContextReportToolEntry[] {
return entries.flatMap((entry) => {
const name = readToolName(entry);
if (!name) {
return [];
}
return [
{
name,
summaryChars: readNonNegativeNumber(() => entry.summaryChars),
schemaChars: readNonNegativeNumber(() => entry.schemaChars),
propertiesCount: readOptionalCount(() => entry.propertiesCount),
},
];
});
}
+3 -2
View File
@@ -5,6 +5,7 @@ import zlib from "node:zlib";
import type { SessionSystemPromptReport } from "../../config/sessions/types.js";
import { resolvePreferredOpenClawTmpDir } from "../../infra/tmp-openclaw-dir.js";
import { estimateTokensFromChars } from "../../utils/cjk-chars.js";
import { readContextReportToolEntries } from "./context-report-tools.js";
type Rect = {
x: number;
@@ -339,8 +340,8 @@ function buildGroups(report: SessionSystemPromptReport): TreemapGroup[] {
const projectFrameChars = Math.max(0, report.systemPrompt.projectContextChars - injectedTotal);
const skillTotal = report.skills.entries.reduce((sum, skill) => sum + skill.blockChars, 0);
const systemBaseChars = Math.max(0, report.systemPrompt.nonProjectContextChars - skillTotal);
const tools = report.tools.entries
.map((tool) => ({ name: tool.name, value: tool.schemaChars ?? 0 }))
const tools = readContextReportToolEntries(report.tools.entries)
.map((tool) => ({ name: tool.name, value: tool.schemaChars }))
.filter((tool) => tool.value > 0);
const currentTurnLeaves = report.currentTurn
? [