mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 23:51:23 +00:00
chore: Enable "curly" rule to avoid single-statement if confusion/errors.
This commit is contained in:
@@ -32,12 +32,16 @@ function normalizeFailureText(text: string): string {
|
||||
}
|
||||
|
||||
function truncateFailureText(text: string, maxChars: number): string {
|
||||
if (text.length <= maxChars) return text;
|
||||
if (text.length <= maxChars) {
|
||||
return text;
|
||||
}
|
||||
return `${text.slice(0, Math.max(0, maxChars - 3))}...`;
|
||||
}
|
||||
|
||||
function formatToolFailureMeta(details: unknown): string | undefined {
|
||||
if (!details || typeof details !== "object") return undefined;
|
||||
if (!details || typeof details !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const record = details as Record<string, unknown>;
|
||||
const status = typeof record.status === "string" ? record.status : undefined;
|
||||
const exitCode =
|
||||
@@ -45,16 +49,24 @@ function formatToolFailureMeta(details: unknown): string | undefined {
|
||||
? record.exitCode
|
||||
: undefined;
|
||||
const parts: string[] = [];
|
||||
if (status) parts.push(`status=${status}`);
|
||||
if (exitCode !== undefined) parts.push(`exitCode=${exitCode}`);
|
||||
if (status) {
|
||||
parts.push(`status=${status}`);
|
||||
}
|
||||
if (exitCode !== undefined) {
|
||||
parts.push(`exitCode=${exitCode}`);
|
||||
}
|
||||
return parts.length > 0 ? parts.join(" ") : undefined;
|
||||
}
|
||||
|
||||
function extractToolResultText(content: unknown): string {
|
||||
if (!Array.isArray(content)) return "";
|
||||
if (!Array.isArray(content)) {
|
||||
return "";
|
||||
}
|
||||
const parts: string[] = [];
|
||||
for (const block of content) {
|
||||
if (!block || typeof block !== "object") continue;
|
||||
if (!block || typeof block !== "object") {
|
||||
continue;
|
||||
}
|
||||
const rec = block as { type?: unknown; text?: unknown };
|
||||
if (rec.type === "text" && typeof rec.text === "string") {
|
||||
parts.push(rec.text);
|
||||
@@ -68,9 +80,13 @@ function collectToolFailures(messages: AgentMessage[]): ToolFailure[] {
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const message of messages) {
|
||||
if (!message || typeof message !== "object") continue;
|
||||
if (!message || typeof message !== "object") {
|
||||
continue;
|
||||
}
|
||||
const role = (message as { role?: unknown }).role;
|
||||
if (role !== "toolResult") continue;
|
||||
if (role !== "toolResult") {
|
||||
continue;
|
||||
}
|
||||
const toolResult = message as {
|
||||
toolCallId?: unknown;
|
||||
toolName?: unknown;
|
||||
@@ -78,9 +94,13 @@ function collectToolFailures(messages: AgentMessage[]): ToolFailure[] {
|
||||
details?: unknown;
|
||||
isError?: unknown;
|
||||
};
|
||||
if (toolResult.isError !== true) continue;
|
||||
if (toolResult.isError !== true) {
|
||||
continue;
|
||||
}
|
||||
const toolCallId = typeof toolResult.toolCallId === "string" ? toolResult.toolCallId : "";
|
||||
if (!toolCallId || seen.has(toolCallId)) continue;
|
||||
if (!toolCallId || seen.has(toolCallId)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(toolCallId);
|
||||
|
||||
const toolName =
|
||||
@@ -101,7 +121,9 @@ function collectToolFailures(messages: AgentMessage[]): ToolFailure[] {
|
||||
}
|
||||
|
||||
function formatToolFailuresSection(failures: ToolFailure[]): string {
|
||||
if (failures.length === 0) return "";
|
||||
if (failures.length === 0) {
|
||||
return "";
|
||||
}
|
||||
const lines = failures.slice(0, MAX_TOOL_FAILURES).map((failure) => {
|
||||
const meta = failure.meta ? ` (${failure.meta})` : "";
|
||||
return `- ${failure.toolName}${meta}: ${failure.summary}`;
|
||||
@@ -130,7 +152,9 @@ function formatFileOperations(readFiles: string[], modifiedFiles: string[]): str
|
||||
if (modifiedFiles.length > 0) {
|
||||
sections.push(`<modified-files>\n${modifiedFiles.join("\n")}\n</modified-files>`);
|
||||
}
|
||||
if (sections.length === 0) return "";
|
||||
if (sections.length === 0) {
|
||||
return "";
|
||||
}
|
||||
return `\n\n${sections.join("\n\n")}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,15 +12,21 @@ import {
|
||||
} from "./context-pruning.js";
|
||||
|
||||
function toolText(msg: AgentMessage): string {
|
||||
if (msg.role !== "toolResult") throw new Error("expected toolResult");
|
||||
if (msg.role !== "toolResult") {
|
||||
throw new Error("expected toolResult");
|
||||
}
|
||||
const first = msg.content.find((b) => b.type === "text");
|
||||
if (!first || first.type !== "text") return "";
|
||||
if (!first || first.type !== "text") {
|
||||
return "";
|
||||
}
|
||||
return first.text;
|
||||
}
|
||||
|
||||
function findToolResult(messages: AgentMessage[], toolCallId: string): AgentMessage {
|
||||
const msg = messages.find((m) => m.role === "toolResult" && m.toolCallId === toolCallId);
|
||||
if (!msg) throw new Error(`missing toolResult: ${toolCallId}`);
|
||||
if (!msg) {
|
||||
throw new Error(`missing toolResult: ${toolCallId}`);
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
@@ -295,14 +301,18 @@ describe("context-pruning", () => {
|
||||
|
||||
contextPruningExtension(api);
|
||||
|
||||
if (!handler) throw new Error("missing context handler");
|
||||
if (!handler) {
|
||||
throw new Error("missing context handler");
|
||||
}
|
||||
|
||||
const result = handler({ messages }, {
|
||||
model: undefined,
|
||||
sessionManager,
|
||||
} as unknown as ExtensionContext);
|
||||
|
||||
if (!result) throw new Error("expected handler to return messages");
|
||||
if (!result) {
|
||||
throw new Error("expected handler to return messages");
|
||||
}
|
||||
expect(toolText(findToolResult(result.messages, "t1"))).toBe("[cleared]");
|
||||
});
|
||||
|
||||
@@ -352,17 +362,23 @@ describe("context-pruning", () => {
|
||||
} as unknown as ExtensionAPI;
|
||||
|
||||
contextPruningExtension(api);
|
||||
if (!handler) throw new Error("missing context handler");
|
||||
if (!handler) {
|
||||
throw new Error("missing context handler");
|
||||
}
|
||||
|
||||
const first = handler({ messages }, {
|
||||
model: undefined,
|
||||
sessionManager,
|
||||
} as unknown as ExtensionContext);
|
||||
if (!first) throw new Error("expected first prune");
|
||||
if (!first) {
|
||||
throw new Error("expected first prune");
|
||||
}
|
||||
expect(toolText(findToolResult(first.messages, "t1"))).toBe("[cleared]");
|
||||
|
||||
const runtime = getContextPruningRuntime(sessionManager);
|
||||
if (!runtime?.lastCacheTouchAt) throw new Error("expected lastCacheTouchAt");
|
||||
if (!runtime?.lastCacheTouchAt) {
|
||||
throw new Error("expected lastCacheTouchAt");
|
||||
}
|
||||
expect(runtime.lastCacheTouchAt).toBeGreaterThan(lastTouch);
|
||||
|
||||
const second = handler({ messages }, {
|
||||
|
||||
@@ -6,7 +6,9 @@ import { getContextPruningRuntime } from "./runtime.js";
|
||||
export default function contextPruningExtension(api: ExtensionAPI): void {
|
||||
api.on("context", (event: ContextEvent, ctx: ExtensionContext) => {
|
||||
const runtime = getContextPruningRuntime(ctx.sessionManager);
|
||||
if (!runtime) return undefined;
|
||||
if (!runtime) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (runtime.settings.mode === "cache-ttl") {
|
||||
const ttlMs = runtime.settings.ttlMs;
|
||||
@@ -27,7 +29,9 @@ export default function contextPruningExtension(api: ExtensionAPI): void {
|
||||
contextWindowTokensOverride: runtime.contextWindowTokens ?? undefined,
|
||||
});
|
||||
|
||||
if (next === event.messages) return undefined;
|
||||
if (next === event.messages) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (runtime.settings.mode === "cache-ttl") {
|
||||
runtime.lastCacheTouchAt = Date.now();
|
||||
|
||||
@@ -17,29 +17,39 @@ function asText(text: string): TextContent {
|
||||
function collectTextSegments(content: ReadonlyArray<TextContent | ImageContent>): string[] {
|
||||
const parts: string[] = [];
|
||||
for (const block of content) {
|
||||
if (block.type === "text") parts.push(block.text);
|
||||
if (block.type === "text") {
|
||||
parts.push(block.text);
|
||||
}
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
function estimateJoinedTextLength(parts: string[]): number {
|
||||
if (parts.length === 0) return 0;
|
||||
if (parts.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
let len = 0;
|
||||
for (const p of parts) len += p.length;
|
||||
for (const p of parts) {
|
||||
len += p.length;
|
||||
}
|
||||
// Joined with "\n" separators between blocks.
|
||||
len += Math.max(0, parts.length - 1);
|
||||
return len;
|
||||
}
|
||||
|
||||
function takeHeadFromJoinedText(parts: string[], maxChars: number): string {
|
||||
if (maxChars <= 0 || parts.length === 0) return "";
|
||||
if (maxChars <= 0 || parts.length === 0) {
|
||||
return "";
|
||||
}
|
||||
let remaining = maxChars;
|
||||
let out = "";
|
||||
for (let i = 0; i < parts.length && remaining > 0; i++) {
|
||||
if (i > 0) {
|
||||
out += "\n";
|
||||
remaining -= 1;
|
||||
if (remaining <= 0) break;
|
||||
if (remaining <= 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const p = parts[i];
|
||||
if (p.length <= remaining) {
|
||||
@@ -54,7 +64,9 @@ function takeHeadFromJoinedText(parts: string[], maxChars: number): string {
|
||||
}
|
||||
|
||||
function takeTailFromJoinedText(parts: string[], maxChars: number): string {
|
||||
if (maxChars <= 0 || parts.length === 0) return "";
|
||||
if (maxChars <= 0 || parts.length === 0) {
|
||||
return "";
|
||||
}
|
||||
let remaining = maxChars;
|
||||
const out: string[] = [];
|
||||
for (let i = parts.length - 1; i >= 0 && remaining > 0; i--) {
|
||||
@@ -78,7 +90,9 @@ function takeTailFromJoinedText(parts: string[], maxChars: number): string {
|
||||
|
||||
function hasImageBlocks(content: ReadonlyArray<TextContent | ImageContent>): boolean {
|
||||
for (const block of content) {
|
||||
if (block.type === "image") return true;
|
||||
if (block.type === "image") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -86,11 +100,17 @@ function hasImageBlocks(content: ReadonlyArray<TextContent | ImageContent>): boo
|
||||
function estimateMessageChars(message: AgentMessage): number {
|
||||
if (message.role === "user") {
|
||||
const content = message.content;
|
||||
if (typeof content === "string") return content.length;
|
||||
if (typeof content === "string") {
|
||||
return content.length;
|
||||
}
|
||||
let chars = 0;
|
||||
for (const b of content) {
|
||||
if (b.type === "text") chars += b.text.length;
|
||||
if (b.type === "image") chars += IMAGE_CHAR_ESTIMATE;
|
||||
if (b.type === "text") {
|
||||
chars += b.text.length;
|
||||
}
|
||||
if (b.type === "image") {
|
||||
chars += IMAGE_CHAR_ESTIMATE;
|
||||
}
|
||||
}
|
||||
return chars;
|
||||
}
|
||||
@@ -98,8 +118,12 @@ function estimateMessageChars(message: AgentMessage): number {
|
||||
if (message.role === "assistant") {
|
||||
let chars = 0;
|
||||
for (const b of message.content) {
|
||||
if (b.type === "text") chars += b.text.length;
|
||||
if (b.type === "thinking") chars += b.thinking.length;
|
||||
if (b.type === "text") {
|
||||
chars += b.text.length;
|
||||
}
|
||||
if (b.type === "thinking") {
|
||||
chars += b.thinking.length;
|
||||
}
|
||||
if (b.type === "toolCall") {
|
||||
try {
|
||||
chars += JSON.stringify(b.arguments ?? {}).length;
|
||||
@@ -114,8 +138,12 @@ function estimateMessageChars(message: AgentMessage): number {
|
||||
if (message.role === "toolResult") {
|
||||
let chars = 0;
|
||||
for (const b of message.content) {
|
||||
if (b.type === "text") chars += b.text.length;
|
||||
if (b.type === "image") chars += IMAGE_CHAR_ESTIMATE;
|
||||
if (b.type === "text") {
|
||||
chars += b.text.length;
|
||||
}
|
||||
if (b.type === "image") {
|
||||
chars += IMAGE_CHAR_ESTIMATE;
|
||||
}
|
||||
}
|
||||
return chars;
|
||||
}
|
||||
@@ -132,13 +160,19 @@ function findAssistantCutoffIndex(
|
||||
keepLastAssistants: number,
|
||||
): number | null {
|
||||
// keepLastAssistants <= 0 => everything is potentially prunable.
|
||||
if (keepLastAssistants <= 0) return messages.length;
|
||||
if (keepLastAssistants <= 0) {
|
||||
return messages.length;
|
||||
}
|
||||
|
||||
let remaining = keepLastAssistants;
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i]?.role !== "assistant") continue;
|
||||
if (messages[i]?.role !== "assistant") {
|
||||
continue;
|
||||
}
|
||||
remaining--;
|
||||
if (remaining === 0) return i;
|
||||
if (remaining === 0) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
// Not enough assistant messages to establish a protected tail.
|
||||
@@ -147,7 +181,9 @@ function findAssistantCutoffIndex(
|
||||
|
||||
function findFirstUserIndex(messages: AgentMessage[]): number | null {
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
if (messages[i]?.role === "user") return i;
|
||||
if (messages[i]?.role === "user") {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -158,15 +194,21 @@ function softTrimToolResultMessage(params: {
|
||||
}): ToolResultMessage | null {
|
||||
const { msg, settings } = params;
|
||||
// Ignore image tool results for now: these are often directly relevant and hard to partially prune safely.
|
||||
if (hasImageBlocks(msg.content)) return null;
|
||||
if (hasImageBlocks(msg.content)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = collectTextSegments(msg.content);
|
||||
const rawLen = estimateJoinedTextLength(parts);
|
||||
if (rawLen <= settings.softTrim.maxChars) return null;
|
||||
if (rawLen <= settings.softTrim.maxChars) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const headChars = Math.max(0, settings.softTrim.headChars);
|
||||
const tailChars = Math.max(0, settings.softTrim.tailChars);
|
||||
if (headChars + tailChars >= rawLen) return null;
|
||||
if (headChars + tailChars >= rawLen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const head = takeHeadFromJoinedText(parts, headChars);
|
||||
const tail = takeTailFromJoinedText(parts, tailChars);
|
||||
@@ -195,13 +237,19 @@ export function pruneContextMessages(params: {
|
||||
params.contextWindowTokensOverride > 0
|
||||
? params.contextWindowTokensOverride
|
||||
: ctx.model?.contextWindow;
|
||||
if (!contextWindowTokens || contextWindowTokens <= 0) return messages;
|
||||
if (!contextWindowTokens || contextWindowTokens <= 0) {
|
||||
return messages;
|
||||
}
|
||||
|
||||
const charWindow = contextWindowTokens * CHARS_PER_TOKEN_ESTIMATE;
|
||||
if (charWindow <= 0) return messages;
|
||||
if (charWindow <= 0) {
|
||||
return messages;
|
||||
}
|
||||
|
||||
const cutoffIndex = findAssistantCutoffIndex(messages, settings.keepLastAssistants);
|
||||
if (cutoffIndex === null) return messages;
|
||||
if (cutoffIndex === null) {
|
||||
return messages;
|
||||
}
|
||||
|
||||
// Bootstrap safety: never prune anything before the first user message. This protects initial
|
||||
// "identity" reads (SOUL.md, USER.md, etc.) which typically happen before the first inbound user
|
||||
@@ -223,8 +271,12 @@ export function pruneContextMessages(params: {
|
||||
|
||||
for (let i = pruneStartIndex; i < cutoffIndex; i++) {
|
||||
const msg = messages[i];
|
||||
if (!msg || msg.role !== "toolResult") continue;
|
||||
if (!isToolPrunable(msg.toolName)) continue;
|
||||
if (!msg || msg.role !== "toolResult") {
|
||||
continue;
|
||||
}
|
||||
if (!isToolPrunable(msg.toolName)) {
|
||||
continue;
|
||||
}
|
||||
if (hasImageBlocks(msg.content)) {
|
||||
continue;
|
||||
}
|
||||
@@ -234,12 +286,16 @@ export function pruneContextMessages(params: {
|
||||
msg: msg as unknown as ToolResultMessage,
|
||||
settings,
|
||||
});
|
||||
if (!updated) continue;
|
||||
if (!updated) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const beforeChars = estimateMessageChars(msg);
|
||||
const afterChars = estimateMessageChars(updated as unknown as AgentMessage);
|
||||
totalChars += afterChars - beforeChars;
|
||||
if (!next) next = messages.slice();
|
||||
if (!next) {
|
||||
next = messages.slice();
|
||||
}
|
||||
next[i] = updated as unknown as AgentMessage;
|
||||
}
|
||||
|
||||
@@ -255,7 +311,9 @@ export function pruneContextMessages(params: {
|
||||
let prunableToolChars = 0;
|
||||
for (const i of prunableToolIndexes) {
|
||||
const msg = outputAfterSoftTrim[i];
|
||||
if (!msg || msg.role !== "toolResult") continue;
|
||||
if (!msg || msg.role !== "toolResult") {
|
||||
continue;
|
||||
}
|
||||
prunableToolChars += estimateMessageChars(msg);
|
||||
}
|
||||
if (prunableToolChars < settings.minPrunableToolChars) {
|
||||
@@ -263,16 +321,22 @@ export function pruneContextMessages(params: {
|
||||
}
|
||||
|
||||
for (const i of prunableToolIndexes) {
|
||||
if (ratio < settings.hardClearRatio) break;
|
||||
if (ratio < settings.hardClearRatio) {
|
||||
break;
|
||||
}
|
||||
const msg = (next ?? messages)[i];
|
||||
if (!msg || msg.role !== "toolResult") continue;
|
||||
if (!msg || msg.role !== "toolResult") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const beforeChars = estimateMessageChars(msg);
|
||||
const cleared: ToolResultMessage = {
|
||||
...msg,
|
||||
content: [asText(settings.hardClear.placeholder)],
|
||||
};
|
||||
if (!next) next = messages.slice();
|
||||
if (!next) {
|
||||
next = messages.slice();
|
||||
}
|
||||
next[i] = cleared as unknown as AgentMessage;
|
||||
const afterChars = estimateMessageChars(cleared as unknown as AgentMessage);
|
||||
totalChars += afterChars - beforeChars;
|
||||
|
||||
@@ -65,9 +65,13 @@ export const DEFAULT_CONTEXT_PRUNING_SETTINGS: EffectiveContextPruningSettings =
|
||||
};
|
||||
|
||||
export function computeEffectiveSettings(raw: unknown): EffectiveContextPruningSettings | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return null;
|
||||
}
|
||||
const cfg = raw as ContextPruningConfig;
|
||||
if (cfg.mode !== "cache-ttl") return null;
|
||||
if (cfg.mode !== "cache-ttl") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const s: EffectiveContextPruningSettings = structuredClone(DEFAULT_CONTEXT_PRUNING_SETTINGS);
|
||||
s.mode = cfg.mode;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { ContextPruningToolMatch } from "./settings.js";
|
||||
|
||||
function normalizePatterns(patterns?: string[]): string[] {
|
||||
if (!Array.isArray(patterns)) return [];
|
||||
if (!Array.isArray(patterns)) {
|
||||
return [];
|
||||
}
|
||||
return patterns
|
||||
.map((p) =>
|
||||
String(p ?? "")
|
||||
@@ -17,8 +19,12 @@ type CompiledPattern =
|
||||
| { kind: "regex"; value: RegExp };
|
||||
|
||||
function compilePattern(pattern: string): CompiledPattern {
|
||||
if (pattern === "*") return { kind: "all" };
|
||||
if (!pattern.includes("*")) return { kind: "exact", value: pattern };
|
||||
if (pattern === "*") {
|
||||
return { kind: "all" };
|
||||
}
|
||||
if (!pattern.includes("*")) {
|
||||
return { kind: "exact", value: pattern };
|
||||
}
|
||||
|
||||
const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const re = new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`);
|
||||
@@ -31,9 +37,15 @@ function compilePatterns(patterns?: string[]): CompiledPattern[] {
|
||||
|
||||
function matchesAny(toolName: string, patterns: CompiledPattern[]): boolean {
|
||||
for (const p of patterns) {
|
||||
if (p.kind === "all") return true;
|
||||
if (p.kind === "exact" && toolName === p.value) return true;
|
||||
if (p.kind === "regex" && p.value.test(toolName)) return true;
|
||||
if (p.kind === "all") {
|
||||
return true;
|
||||
}
|
||||
if (p.kind === "exact" && toolName === p.value) {
|
||||
return true;
|
||||
}
|
||||
if (p.kind === "regex" && p.value.test(toolName)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -46,8 +58,12 @@ export function makeToolPrunablePredicate(
|
||||
|
||||
return (toolName: string) => {
|
||||
const normalized = toolName.trim().toLowerCase();
|
||||
if (matchesAny(normalized, deny)) return false;
|
||||
if (allow.length === 0) return true;
|
||||
if (matchesAny(normalized, deny)) {
|
||||
return false;
|
||||
}
|
||||
if (allow.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return matchesAny(normalized, allow);
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user