refactor(agents): dedupe config and truncation guards

This commit is contained in:
Peter Steinberger
2026-02-22 17:54:42 +00:00
parent 409a02691f
commit 3286791316
12 changed files with 325 additions and 318 deletions

View File

@@ -2,7 +2,9 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core";
import { describe, expect, it } from "vitest";
import {
truncateToolResultText,
truncateToolResultMessage,
calculateMaxToolResultChars,
getToolResultTextLength,
truncateOversizedToolResultsInMessages,
isOversizedToolResult,
sessionLikelyHasOversizedToolResults,
@@ -82,6 +84,55 @@ describe("truncateToolResultText", () => {
expect(lastNewline).toBeGreaterThan(keptContent.length - 100);
}
});
it("supports custom suffix and min keep chars", () => {
const text = "x".repeat(5_000);
const result = truncateToolResultText(text, 300, {
suffix: "\n\n[custom-truncated]",
minKeepChars: 250,
});
expect(result).toContain("[custom-truncated]");
expect(result.length).toBeGreaterThan(250);
});
});
describe("getToolResultTextLength", () => {
it("sums all text blocks in tool results", () => {
const msg = {
role: "toolResult",
content: [
{ type: "text", text: "abc" },
{ type: "image", source: { type: "base64", mediaType: "image/png", data: "x" } },
{ type: "text", text: "12345" },
],
} as unknown as AgentMessage;
expect(getToolResultTextLength(msg)).toBe(8);
});
it("returns zero for non-toolResult messages", () => {
expect(getToolResultTextLength(makeAssistantMessage("hello"))).toBe(0);
});
});
describe("truncateToolResultMessage", () => {
it("truncates with a custom suffix", () => {
const msg = {
role: "toolResult",
toolCallId: "call_1",
toolName: "read",
content: [{ type: "text", text: "x".repeat(50_000) }],
isError: false,
timestamp: Date.now(),
} as unknown as AgentMessage;
const result = truncateToolResultMessage(msg, 10_000, {
suffix: "\n\n[persist-truncated]",
minKeepChars: 2_000,
}) as { content: Array<{ type: string; text: string }> };
expect(result.content[0]?.text).toContain("[persist-truncated]");
});
});
describe("calculateMaxToolResultChars", () => {

View File

@@ -33,21 +33,32 @@ const TRUNCATION_SUFFIX =
"The content above is a partial view. If you need more, request specific sections or use " +
"offset/limit parameters to read smaller chunks.]";
type ToolResultTruncationOptions = {
suffix?: string;
minKeepChars?: number;
};
/**
* Truncate a single text string to fit within maxChars, preserving the beginning.
*/
export function truncateToolResultText(text: string, maxChars: number): string {
export function truncateToolResultText(
text: string,
maxChars: number,
options: ToolResultTruncationOptions = {},
): string {
const suffix = options.suffix ?? TRUNCATION_SUFFIX;
const minKeepChars = options.minKeepChars ?? MIN_KEEP_CHARS;
if (text.length <= maxChars) {
return text;
}
const keepChars = Math.max(MIN_KEEP_CHARS, maxChars - TRUNCATION_SUFFIX.length);
const keepChars = Math.max(minKeepChars, maxChars - suffix.length);
// Try to break at a newline boundary to avoid cutting mid-line
let cutPoint = keepChars;
const lastNewline = text.lastIndexOf("\n", keepChars);
if (lastNewline > keepChars * 0.8) {
cutPoint = lastNewline;
}
return text.slice(0, cutPoint) + TRUNCATION_SUFFIX;
return text.slice(0, cutPoint) + suffix;
}
/**
@@ -67,7 +78,7 @@ export function calculateMaxToolResultChars(contextWindowTokens: number): number
/**
* Get the total character count of text content blocks in a tool result message.
*/
function getToolResultTextLength(msg: AgentMessage): number {
export function getToolResultTextLength(msg: AgentMessage): number {
if (!msg || (msg as { role?: string }).role !== "toolResult") {
return 0;
}
@@ -91,7 +102,13 @@ function getToolResultTextLength(msg: AgentMessage): number {
* Truncate a tool result message's text content blocks to fit within maxChars.
* Returns a new message (does not mutate the original).
*/
function truncateToolResultMessage(msg: AgentMessage, maxChars: number): AgentMessage {
export function truncateToolResultMessage(
msg: AgentMessage,
maxChars: number,
options: ToolResultTruncationOptions = {},
): AgentMessage {
const suffix = options.suffix ?? TRUNCATION_SUFFIX;
const minKeepChars = options.minKeepChars ?? MIN_KEEP_CHARS;
const content = (msg as { content?: unknown }).content;
if (!Array.isArray(content)) {
return msg;
@@ -114,10 +131,10 @@ function truncateToolResultMessage(msg: AgentMessage, maxChars: number): AgentMe
}
// Proportional budget for this block
const blockShare = textBlock.text.length / totalTextChars;
const blockBudget = Math.max(MIN_KEEP_CHARS, Math.floor(maxChars * blockShare));
const blockBudget = Math.max(minKeepChars + suffix.length, Math.floor(maxChars * blockShare));
return {
...textBlock,
text: truncateToolResultText(textBlock.text, blockBudget),
text: truncateToolResultText(textBlock.text, blockBudget, { suffix, minKeepChars }),
};
});

View File

@@ -1,22 +1,13 @@
export type SandboxDockerConfig = {
image: string;
containerPrefix: string;
workdir: string;
readOnlyRoot: boolean;
tmpfs: string[];
network: string;
user?: string;
capDrop: string[];
env?: Record<string, string>;
setupCommand?: string;
pidsLimit?: number;
memory?: string | number;
memorySwap?: string | number;
cpus?: number;
ulimits?: Record<string, string | number | { soft?: number; hard?: number }>;
seccompProfile?: string;
apparmorProfile?: string;
dns?: string[];
extraHosts?: string[];
binds?: string[];
};
import type { SandboxDockerSettings } from "../../config/types.sandbox.js";
type RequiredDockerConfigKeys =
| "image"
| "containerPrefix"
| "workdir"
| "readOnlyRoot"
| "tmpfs"
| "network"
| "capDrop";
export type SandboxDockerConfig = Omit<SandboxDockerSettings, RequiredDockerConfigKeys> &
Required<Pick<SandboxDockerSettings, RequiredDockerConfigKeys>>;

View File

@@ -1,12 +1,14 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { TextContent } from "@mariozechner/pi-ai";
import type { SessionManager } from "@mariozechner/pi-coding-agent";
import type {
PluginHookBeforeMessageWriteEvent,
PluginHookBeforeMessageWriteResult,
} from "../plugins/types.js";
import { emitSessionTranscriptUpdate } from "../sessions/transcript-events.js";
import { HARD_MAX_TOOL_RESULT_CHARS } from "./pi-embedded-runner/tool-result-truncation.js";
import {
HARD_MAX_TOOL_RESULT_CHARS,
truncateToolResultMessage,
} from "./pi-embedded-runner/tool-result-truncation.js";
import { makeMissingToolResult, sanitizeToolCallInputs } from "./session-transcript-repair.js";
import { extractToolCallsFromAssistant, extractToolResultId } from "./tool-call-id.js";
@@ -20,60 +22,13 @@ const GUARD_TRUNCATION_SUFFIX =
* truncated text blocks otherwise.
*/
function capToolResultSize(msg: AgentMessage): AgentMessage {
const role = (msg as { role?: string }).role;
if (role !== "toolResult") {
if ((msg as { role?: string }).role !== "toolResult") {
return msg;
}
const content = (msg as { content?: unknown }).content;
if (!Array.isArray(content)) {
return msg;
}
// Calculate total text size
let totalTextChars = 0;
for (const block of content) {
if (block && typeof block === "object" && (block as { type?: string }).type === "text") {
const text = (block as TextContent).text;
if (typeof text === "string") {
totalTextChars += text.length;
}
}
}
if (totalTextChars <= HARD_MAX_TOOL_RESULT_CHARS) {
return msg;
}
// Truncate proportionally
const newContent = content.map((block: unknown) => {
if (!block || typeof block !== "object" || (block as { type?: string }).type !== "text") {
return block;
}
const textBlock = block as TextContent;
if (typeof textBlock.text !== "string") {
return block;
}
const blockShare = textBlock.text.length / totalTextChars;
const blockBudget = Math.max(
2_000,
Math.floor(HARD_MAX_TOOL_RESULT_CHARS * blockShare) - GUARD_TRUNCATION_SUFFIX.length,
);
if (textBlock.text.length <= blockBudget) {
return block;
}
// Try to cut at a newline boundary
let cutPoint = blockBudget;
const lastNewline = textBlock.text.lastIndexOf("\n", blockBudget);
if (lastNewline > blockBudget * 0.8) {
cutPoint = lastNewline;
}
return {
...textBlock,
text: textBlock.text.slice(0, cutPoint) + GUARD_TRUNCATION_SUFFIX,
};
return truncateToolResultMessage(msg, HARD_MAX_TOOL_RESULT_CHARS, {
suffix: GUARD_TRUNCATION_SUFFIX,
minKeepChars: 2_000,
});
return { ...msg, content: newContent } as AgentMessage;
}
export function installSessionToolResultGuard(

View File

@@ -1,6 +1,6 @@
import type { OpenClawConfig, SkillConfig } from "../../config/config.js";
import {
evaluateRuntimeRequires,
evaluateRuntimeEligibility,
hasBinary,
isConfigPathTruthyWithDefaults,
resolveConfigPath,
@@ -76,8 +76,6 @@ export function shouldIncludeSkill(params: {
const skillKey = resolveSkillKey(entry.skill, entry);
const skillConfig = resolveSkillConfig(config, skillKey);
const allowBundled = normalizeAllowlist(config?.skills?.allowBundled);
const osList = entry.metadata?.os ?? [];
const remotePlatforms = eligibility?.remote?.platforms ?? [];
if (skillConfig?.enabled === false) {
return false;
@@ -85,18 +83,10 @@ export function shouldIncludeSkill(params: {
if (!isBundledSkillAllowed(entry, allowBundled)) {
return false;
}
if (
osList.length > 0 &&
!osList.includes(resolveRuntimePlatform()) &&
!remotePlatforms.some((platform) => osList.includes(platform))
) {
return false;
}
if (entry.metadata?.always === true) {
return true;
}
return evaluateRuntimeRequires({
return evaluateRuntimeEligibility({
os: entry.metadata?.os,
remotePlatforms: eligibility?.remote?.platforms,
always: entry.metadata?.always,
requires: entry.metadata?.requires,
hasBin: hasBinary,
hasRemoteBin: eligibility?.remote?.hasBin,