2026-02-26 13:32:02 +01:00
|
|
|
|
import fs from "node:fs/promises";
|
2026-02-22 21:33:46 +01:00
|
|
|
|
import path from "node:path";
|
|
|
|
|
|
import { fileURLToPath } from "node:url";
|
2026-01-14 05:39:59 +00:00
|
|
|
|
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
2026-01-14 14:31:43 +00:00
|
|
|
|
import { createEditTool, createReadTool, createWriteTool } from "@mariozechner/pi-coding-agent";
|
2026-03-02 01:03:40 +00:00
|
|
|
|
import {
|
2026-03-10 12:44:33 +08:00
|
|
|
|
appendFileWithinRoot,
|
2026-03-02 01:03:40 +00:00
|
|
|
|
SafeOpenError,
|
|
|
|
|
|
openFileWithinRoot,
|
|
|
|
|
|
readFileWithinRoot,
|
|
|
|
|
|
writeFileWithinRoot,
|
|
|
|
|
|
} from "../infra/fs-safe.js";
|
2026-02-18 01:29:02 +00:00
|
|
|
|
import { detectMime } from "../media/mime.js";
|
|
|
|
|
|
import { sniffMimeFromBase64 } from "../media/sniff-mime-from-base64.js";
|
2026-02-18 01:34:35 +00:00
|
|
|
|
import type { ImageSanitizationLimits } from "./image-sanitization.js";
|
2026-03-02 04:04:02 +00:00
|
|
|
|
import { toRelativeWorkspacePath } from "./path-policy.js";
|
2026-03-03 02:19:04 +00:00
|
|
|
|
import { wrapHostEditToolWithPostWriteRecovery } from "./pi-tools.host-edit.js";
|
|
|
|
|
|
import {
|
|
|
|
|
|
CLAUDE_PARAM_GROUPS,
|
|
|
|
|
|
assertRequiredParams,
|
|
|
|
|
|
normalizeToolParams,
|
|
|
|
|
|
patchToolSchemaForClaudeCompatibility,
|
|
|
|
|
|
wrapToolParamNormalization,
|
|
|
|
|
|
} from "./pi-tools.params.js";
|
2026-02-18 01:34:35 +00:00
|
|
|
|
import type { AnyAgentTool } from "./pi-tools.types.js";
|
2026-02-18 01:29:02 +00:00
|
|
|
|
import { assertSandboxPath } from "./sandbox-paths.js";
|
2026-02-18 01:34:35 +00:00
|
|
|
|
import type { SandboxFsBridge } from "./sandbox/fs-bridge.js";
|
2026-01-14 05:39:59 +00:00
|
|
|
|
import { sanitizeToolResultImages } from "./tool-images.js";
|
|
|
|
|
|
|
2026-03-03 02:19:04 +00:00
|
|
|
|
export {
|
|
|
|
|
|
CLAUDE_PARAM_GROUPS,
|
2026-03-03 08:11:42 +05:30
|
|
|
|
assertRequiredParams,
|
2026-03-03 02:19:04 +00:00
|
|
|
|
normalizeToolParams,
|
|
|
|
|
|
patchToolSchemaForClaudeCompatibility,
|
|
|
|
|
|
wrapToolParamNormalization,
|
|
|
|
|
|
} from "./pi-tools.params.js";
|
|
|
|
|
|
|
2026-01-14 05:39:59 +00:00
|
|
|
|
// NOTE(steipete): Upstream read now does file-magic MIME detection; we keep the wrapper
|
|
|
|
|
|
// to normalize payloads and sanitize oversized images before they hit providers.
|
|
|
|
|
|
type ToolContentBlock = AgentToolResult<unknown>["content"][number];
|
|
|
|
|
|
type ImageContentBlock = Extract<ToolContentBlock, { type: "image" }>;
|
|
|
|
|
|
type TextContentBlock = Extract<ToolContentBlock, { type: "text" }>;
|
|
|
|
|
|
|
fix(subagent): harden read-tool overflow guards and sticky reply threading (#19508)
* fix(gateway): avoid premature agent.wait completion on transient errors
* fix(agent): preemptively guard tool results against context overflow
* fix: harden tool-result context guard and add message_id metadata
* fix: use importOriginal in session-key mock to include DEFAULT_ACCOUNT_ID
The run.skill-filter test was mocking ../../routing/session-key.js with only
buildAgentMainSessionKey and normalizeAgentId, but the module also exports
DEFAULT_ACCOUNT_ID which is required transitively by src/web/auth-store.ts.
Switch to importOriginal pattern so all real exports are preserved alongside
the mocked functions.
* pi-runner: guard accumulated tool-result overflow in transformContext
* PI runner: compact overflowing tool-result context
* Subagent: harden tool-result context recovery
* Enhance tool-result context handling by adding support for legacy tool outputs and improving character estimation for message truncation. This includes a new function to create legacy tool results and updates to existing functions to better manage context overflow scenarios.
* Enhance iMessage handling by adding reply tag support in send functions and tests. This includes modifications to prepend or rewrite reply tags based on provided replyToId, ensuring proper message formatting for replies.
* Enhance message delivery across multiple channels by implementing sticky reply context for chunked messages. This includes preserving reply references in Discord, Telegram, and iMessage, ensuring that follow-up messages maintain their intended reply targets. Additionally, improve handling of reply tags in system prompts and tests to support consistent reply behavior.
* Enhance read tool functionality by implementing auto-paging across chunks when no explicit limit is provided, scaling output budget based on model context window. Additionally, add tests for adaptive reading behavior and capped continuation guidance for large outputs. Update related functions to support these features.
* Refine tool-result context management by stripping oversized read-tool details payloads during compaction, ensuring repeated read calls do not bypass context limits. Introduce new utility functions for handling truncation content and enhance character estimation for tool results. Add tests to validate the removal of excessive details in context overflow scenarios.
* Refine message delivery logic in Matrix and Telegram by introducing a flag to track if a text chunk was sent. This ensures that replies are only marked as delivered when a text chunk has been successfully sent, improving the accuracy of reply handling in both channels.
* fix: tighten reply threading coverage and prep fixes (#19508) (thanks @tyler6204)
2026-02-17 15:32:52 -08:00
|
|
|
|
const DEFAULT_READ_PAGE_MAX_BYTES = 50 * 1024;
|
|
|
|
|
|
const MAX_ADAPTIVE_READ_MAX_BYTES = 512 * 1024;
|
|
|
|
|
|
const ADAPTIVE_READ_CONTEXT_SHARE = 0.2;
|
|
|
|
|
|
const CHARS_PER_TOKEN_ESTIMATE = 4;
|
|
|
|
|
|
const MAX_ADAPTIVE_READ_PAGES = 8;
|
|
|
|
|
|
|
|
|
|
|
|
type OpenClawReadToolOptions = {
|
|
|
|
|
|
modelContextWindowTokens?: number;
|
2026-02-18 00:43:31 +01:00
|
|
|
|
imageSanitization?: ImageSanitizationLimits;
|
fix(subagent): harden read-tool overflow guards and sticky reply threading (#19508)
* fix(gateway): avoid premature agent.wait completion on transient errors
* fix(agent): preemptively guard tool results against context overflow
* fix: harden tool-result context guard and add message_id metadata
* fix: use importOriginal in session-key mock to include DEFAULT_ACCOUNT_ID
The run.skill-filter test was mocking ../../routing/session-key.js with only
buildAgentMainSessionKey and normalizeAgentId, but the module also exports
DEFAULT_ACCOUNT_ID which is required transitively by src/web/auth-store.ts.
Switch to importOriginal pattern so all real exports are preserved alongside
the mocked functions.
* pi-runner: guard accumulated tool-result overflow in transformContext
* PI runner: compact overflowing tool-result context
* Subagent: harden tool-result context recovery
* Enhance tool-result context handling by adding support for legacy tool outputs and improving character estimation for message truncation. This includes a new function to create legacy tool results and updates to existing functions to better manage context overflow scenarios.
* Enhance iMessage handling by adding reply tag support in send functions and tests. This includes modifications to prepend or rewrite reply tags based on provided replyToId, ensuring proper message formatting for replies.
* Enhance message delivery across multiple channels by implementing sticky reply context for chunked messages. This includes preserving reply references in Discord, Telegram, and iMessage, ensuring that follow-up messages maintain their intended reply targets. Additionally, improve handling of reply tags in system prompts and tests to support consistent reply behavior.
* Enhance read tool functionality by implementing auto-paging across chunks when no explicit limit is provided, scaling output budget based on model context window. Additionally, add tests for adaptive reading behavior and capped continuation guidance for large outputs. Update related functions to support these features.
* Refine tool-result context management by stripping oversized read-tool details payloads during compaction, ensuring repeated read calls do not bypass context limits. Introduce new utility functions for handling truncation content and enhance character estimation for tool results. Add tests to validate the removal of excessive details in context overflow scenarios.
* Refine message delivery logic in Matrix and Telegram by introducing a flag to track if a text chunk was sent. This ensures that replies are only marked as delivered when a text chunk has been successfully sent, improving the accuracy of reply handling in both channels.
* fix: tighten reply threading coverage and prep fixes (#19508) (thanks @tyler6204)
2026-02-17 15:32:52 -08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
type ReadTruncationDetails = {
|
|
|
|
|
|
truncated: boolean;
|
|
|
|
|
|
outputLines: number;
|
|
|
|
|
|
firstLineExceedsLimit: boolean;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const READ_CONTINUATION_NOTICE_RE =
|
|
|
|
|
|
/\n\n\[(?:Showing lines [^\]]*?Use offset=\d+ to continue\.|\d+ more lines in file\. Use offset=\d+ to continue\.)\]\s*$/;
|
|
|
|
|
|
|
|
|
|
|
|
function clamp(value: number, min: number, max: number): number {
|
|
|
|
|
|
return Math.max(min, Math.min(max, value));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resolveAdaptiveReadMaxBytes(options?: OpenClawReadToolOptions): number {
|
|
|
|
|
|
const contextWindowTokens = options?.modelContextWindowTokens;
|
|
|
|
|
|
if (
|
|
|
|
|
|
typeof contextWindowTokens !== "number" ||
|
|
|
|
|
|
!Number.isFinite(contextWindowTokens) ||
|
|
|
|
|
|
contextWindowTokens <= 0
|
|
|
|
|
|
) {
|
|
|
|
|
|
return DEFAULT_READ_PAGE_MAX_BYTES;
|
|
|
|
|
|
}
|
|
|
|
|
|
const fromContext = Math.floor(
|
|
|
|
|
|
contextWindowTokens * CHARS_PER_TOKEN_ESTIMATE * ADAPTIVE_READ_CONTEXT_SHARE,
|
|
|
|
|
|
);
|
|
|
|
|
|
return clamp(fromContext, DEFAULT_READ_PAGE_MAX_BYTES, MAX_ADAPTIVE_READ_MAX_BYTES);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatBytes(bytes: number): string {
|
|
|
|
|
|
if (bytes >= 1024 * 1024) {
|
|
|
|
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (bytes >= 1024) {
|
|
|
|
|
|
return `${Math.round(bytes / 1024)}KB`;
|
|
|
|
|
|
}
|
|
|
|
|
|
return `${bytes}B`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getToolResultText(result: AgentToolResult<unknown>): string | undefined {
|
|
|
|
|
|
const content = Array.isArray(result.content) ? result.content : [];
|
|
|
|
|
|
const textBlocks = content
|
|
|
|
|
|
.map((block) => {
|
|
|
|
|
|
if (
|
|
|
|
|
|
block &&
|
|
|
|
|
|
typeof block === "object" &&
|
|
|
|
|
|
(block as { type?: unknown }).type === "text" &&
|
|
|
|
|
|
typeof (block as { text?: unknown }).text === "string"
|
|
|
|
|
|
) {
|
|
|
|
|
|
return (block as { text: string }).text;
|
|
|
|
|
|
}
|
|
|
|
|
|
return undefined;
|
|
|
|
|
|
})
|
|
|
|
|
|
.filter((value): value is string => typeof value === "string");
|
|
|
|
|
|
if (textBlocks.length === 0) {
|
|
|
|
|
|
return undefined;
|
|
|
|
|
|
}
|
|
|
|
|
|
return textBlocks.join("\n");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function withToolResultText(
|
|
|
|
|
|
result: AgentToolResult<unknown>,
|
|
|
|
|
|
text: string,
|
|
|
|
|
|
): AgentToolResult<unknown> {
|
|
|
|
|
|
const content = Array.isArray(result.content) ? result.content : [];
|
|
|
|
|
|
let replaced = false;
|
|
|
|
|
|
const nextContent: ToolContentBlock[] = content.map((block) => {
|
|
|
|
|
|
if (
|
|
|
|
|
|
!replaced &&
|
|
|
|
|
|
block &&
|
|
|
|
|
|
typeof block === "object" &&
|
|
|
|
|
|
(block as { type?: unknown }).type === "text"
|
|
|
|
|
|
) {
|
|
|
|
|
|
replaced = true;
|
|
|
|
|
|
return {
|
|
|
|
|
|
...(block as TextContentBlock),
|
|
|
|
|
|
text,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
return block;
|
|
|
|
|
|
});
|
|
|
|
|
|
if (replaced) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
...result,
|
|
|
|
|
|
content: nextContent as unknown as AgentToolResult<unknown>["content"],
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
const textBlock = { type: "text", text } as unknown as TextContentBlock;
|
|
|
|
|
|
return {
|
|
|
|
|
|
...result,
|
|
|
|
|
|
content: [textBlock] as unknown as AgentToolResult<unknown>["content"],
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function extractReadTruncationDetails(
|
|
|
|
|
|
result: AgentToolResult<unknown>,
|
|
|
|
|
|
): ReadTruncationDetails | null {
|
|
|
|
|
|
const details = (result as { details?: unknown }).details;
|
|
|
|
|
|
if (!details || typeof details !== "object") {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
const truncation = (details as { truncation?: unknown }).truncation;
|
|
|
|
|
|
if (!truncation || typeof truncation !== "object") {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
const record = truncation as Record<string, unknown>;
|
|
|
|
|
|
if (record.truncated !== true) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
const outputLinesRaw = record.outputLines;
|
|
|
|
|
|
const outputLines =
|
|
|
|
|
|
typeof outputLinesRaw === "number" && Number.isFinite(outputLinesRaw)
|
|
|
|
|
|
? Math.max(0, Math.floor(outputLinesRaw))
|
|
|
|
|
|
: 0;
|
|
|
|
|
|
return {
|
|
|
|
|
|
truncated: true,
|
|
|
|
|
|
outputLines,
|
|
|
|
|
|
firstLineExceedsLimit: record.firstLineExceedsLimit === true,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function stripReadContinuationNotice(text: string): string {
|
|
|
|
|
|
return text.replace(READ_CONTINUATION_NOTICE_RE, "");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function stripReadTruncationContentDetails(
|
|
|
|
|
|
result: AgentToolResult<unknown>,
|
|
|
|
|
|
): AgentToolResult<unknown> {
|
|
|
|
|
|
const details = (result as { details?: unknown }).details;
|
|
|
|
|
|
if (!details || typeof details !== "object") {
|
|
|
|
|
|
return result;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const detailsRecord = details as Record<string, unknown>;
|
|
|
|
|
|
const truncationRaw = detailsRecord.truncation;
|
|
|
|
|
|
if (!truncationRaw || typeof truncationRaw !== "object") {
|
|
|
|
|
|
return result;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const truncation = truncationRaw as Record<string, unknown>;
|
|
|
|
|
|
if (!Object.prototype.hasOwnProperty.call(truncation, "content")) {
|
|
|
|
|
|
return result;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const { content: _content, ...restTruncation } = truncation;
|
|
|
|
|
|
return {
|
|
|
|
|
|
...result,
|
|
|
|
|
|
details: {
|
|
|
|
|
|
...detailsRecord,
|
|
|
|
|
|
truncation: restTruncation,
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function executeReadWithAdaptivePaging(params: {
|
|
|
|
|
|
base: AnyAgentTool;
|
|
|
|
|
|
toolCallId: string;
|
|
|
|
|
|
args: Record<string, unknown>;
|
|
|
|
|
|
signal?: AbortSignal;
|
|
|
|
|
|
maxBytes: number;
|
|
|
|
|
|
}): Promise<AgentToolResult<unknown>> {
|
|
|
|
|
|
const userLimit = params.args.limit;
|
|
|
|
|
|
const hasExplicitLimit =
|
|
|
|
|
|
typeof userLimit === "number" && Number.isFinite(userLimit) && userLimit > 0;
|
|
|
|
|
|
if (hasExplicitLimit) {
|
|
|
|
|
|
return await params.base.execute(params.toolCallId, params.args, params.signal);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const offsetRaw = params.args.offset;
|
|
|
|
|
|
let nextOffset =
|
|
|
|
|
|
typeof offsetRaw === "number" && Number.isFinite(offsetRaw) && offsetRaw > 0
|
|
|
|
|
|
? Math.floor(offsetRaw)
|
|
|
|
|
|
: 1;
|
|
|
|
|
|
let firstResult: AgentToolResult<unknown> | null = null;
|
|
|
|
|
|
let aggregatedText = "";
|
|
|
|
|
|
let aggregatedBytes = 0;
|
|
|
|
|
|
let capped = false;
|
|
|
|
|
|
let continuationOffset: number | undefined;
|
|
|
|
|
|
|
|
|
|
|
|
for (let page = 0; page < MAX_ADAPTIVE_READ_PAGES; page += 1) {
|
|
|
|
|
|
const pageArgs = { ...params.args, offset: nextOffset };
|
|
|
|
|
|
const pageResult = await params.base.execute(params.toolCallId, pageArgs, params.signal);
|
|
|
|
|
|
firstResult ??= pageResult;
|
|
|
|
|
|
|
|
|
|
|
|
const rawText = getToolResultText(pageResult);
|
|
|
|
|
|
if (typeof rawText !== "string") {
|
|
|
|
|
|
return pageResult;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const truncation = extractReadTruncationDetails(pageResult);
|
|
|
|
|
|
const canContinue =
|
|
|
|
|
|
Boolean(truncation?.truncated) &&
|
|
|
|
|
|
!truncation?.firstLineExceedsLimit &&
|
|
|
|
|
|
(truncation?.outputLines ?? 0) > 0 &&
|
|
|
|
|
|
page < MAX_ADAPTIVE_READ_PAGES - 1;
|
|
|
|
|
|
const pageText = canContinue ? stripReadContinuationNotice(rawText) : rawText;
|
|
|
|
|
|
const delimiter = aggregatedText ? "\n\n" : "";
|
|
|
|
|
|
const nextBytes = Buffer.byteLength(`${delimiter}${pageText}`, "utf-8");
|
|
|
|
|
|
|
|
|
|
|
|
if (aggregatedText && aggregatedBytes + nextBytes > params.maxBytes) {
|
|
|
|
|
|
capped = true;
|
|
|
|
|
|
continuationOffset = nextOffset;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
aggregatedText += `${delimiter}${pageText}`;
|
|
|
|
|
|
aggregatedBytes += nextBytes;
|
|
|
|
|
|
|
|
|
|
|
|
if (!canContinue || !truncation) {
|
|
|
|
|
|
return withToolResultText(pageResult, aggregatedText);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
nextOffset += truncation.outputLines;
|
|
|
|
|
|
continuationOffset = nextOffset;
|
|
|
|
|
|
|
|
|
|
|
|
if (aggregatedBytes >= params.maxBytes) {
|
|
|
|
|
|
capped = true;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!firstResult) {
|
|
|
|
|
|
return await params.base.execute(params.toolCallId, params.args, params.signal);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let finalText = aggregatedText;
|
|
|
|
|
|
if (capped && continuationOffset) {
|
|
|
|
|
|
finalText += `\n\n[Read output capped at ${formatBytes(params.maxBytes)} for this call. Use offset=${continuationOffset} to continue.]`;
|
|
|
|
|
|
}
|
|
|
|
|
|
return withToolResultText(firstResult, finalText);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-14 05:39:59 +00:00
|
|
|
|
function rewriteReadImageHeader(text: string, mimeType: string): string {
|
|
|
|
|
|
// pi-coding-agent uses: "Read image file [image/png]"
|
|
|
|
|
|
if (text.startsWith("Read image file [") && text.endsWith("]")) {
|
|
|
|
|
|
return `Read image file [${mimeType}]`;
|
|
|
|
|
|
}
|
|
|
|
|
|
return text;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function normalizeReadImageResult(
|
|
|
|
|
|
result: AgentToolResult<unknown>,
|
|
|
|
|
|
filePath: string,
|
|
|
|
|
|
): Promise<AgentToolResult<unknown>> {
|
|
|
|
|
|
const content = Array.isArray(result.content) ? result.content : [];
|
|
|
|
|
|
|
|
|
|
|
|
const image = content.find(
|
|
|
|
|
|
(b): b is ImageContentBlock =>
|
|
|
|
|
|
!!b &&
|
|
|
|
|
|
typeof b === "object" &&
|
|
|
|
|
|
(b as { type?: unknown }).type === "image" &&
|
|
|
|
|
|
typeof (b as { data?: unknown }).data === "string" &&
|
|
|
|
|
|
typeof (b as { mimeType?: unknown }).mimeType === "string",
|
|
|
|
|
|
);
|
2026-01-31 16:19:20 +09:00
|
|
|
|
if (!image) {
|
|
|
|
|
|
return result;
|
|
|
|
|
|
}
|
2026-01-14 05:39:59 +00:00
|
|
|
|
|
|
|
|
|
|
if (!image.data.trim()) {
|
|
|
|
|
|
throw new Error(`read: image payload is empty (${filePath})`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const sniffed = await sniffMimeFromBase64(image.data);
|
2026-01-31 16:19:20 +09:00
|
|
|
|
if (!sniffed) {
|
|
|
|
|
|
return result;
|
|
|
|
|
|
}
|
2026-01-14 05:39:59 +00:00
|
|
|
|
|
|
|
|
|
|
if (!sniffed.startsWith("image/")) {
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
`read: file looks like ${sniffed} but was treated as ${image.mimeType} (${filePath})`,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-31 16:19:20 +09:00
|
|
|
|
if (sniffed === image.mimeType) {
|
|
|
|
|
|
return result;
|
|
|
|
|
|
}
|
2026-01-14 05:39:59 +00:00
|
|
|
|
|
|
|
|
|
|
const nextContent = content.map((block) => {
|
2026-01-14 14:31:43 +00:00
|
|
|
|
if (block && typeof block === "object" && (block as { type?: unknown }).type === "image") {
|
2026-01-14 05:39:59 +00:00
|
|
|
|
const b = block as ImageContentBlock & { mimeType: string };
|
|
|
|
|
|
return { ...b, mimeType: sniffed } satisfies ImageContentBlock;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (
|
|
|
|
|
|
block &&
|
|
|
|
|
|
typeof block === "object" &&
|
|
|
|
|
|
(block as { type?: unknown }).type === "text" &&
|
|
|
|
|
|
typeof (block as { text?: unknown }).text === "string"
|
|
|
|
|
|
) {
|
|
|
|
|
|
const b = block as TextContentBlock & { text: string };
|
|
|
|
|
|
return {
|
|
|
|
|
|
...b,
|
|
|
|
|
|
text: rewriteReadImageHeader(b.text, sniffed),
|
|
|
|
|
|
} satisfies TextContentBlock;
|
|
|
|
|
|
}
|
|
|
|
|
|
return block;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return { ...result, content: nextContent };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-14 23:50:04 +01:00
|
|
|
|
export function wrapToolWorkspaceRootGuard(tool: AnyAgentTool, root: string): AnyAgentTool {
|
2026-02-22 21:33:46 +01:00
|
|
|
|
return wrapToolWorkspaceRootGuardWithOptions(tool, root);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function mapContainerPathToWorkspaceRoot(params: {
|
|
|
|
|
|
filePath: string;
|
|
|
|
|
|
root: string;
|
|
|
|
|
|
containerWorkdir?: string;
|
|
|
|
|
|
}): string {
|
|
|
|
|
|
const containerWorkdir = params.containerWorkdir?.trim();
|
|
|
|
|
|
if (!containerWorkdir) {
|
|
|
|
|
|
return params.filePath;
|
|
|
|
|
|
}
|
|
|
|
|
|
const normalizedWorkdir = containerWorkdir.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
|
|
|
|
if (!normalizedWorkdir.startsWith("/")) {
|
|
|
|
|
|
return params.filePath;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!normalizedWorkdir) {
|
|
|
|
|
|
return params.filePath;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-24 17:22:46 +00:00
|
|
|
|
let candidate = params.filePath.startsWith("@") ? params.filePath.slice(1) : params.filePath;
|
2026-02-22 21:33:46 +01:00
|
|
|
|
if (/^file:\/\//i.test(candidate)) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
candidate = fileURLToPath(candidate);
|
|
|
|
|
|
} catch {
|
2026-02-22 21:52:10 +00:00
|
|
|
|
try {
|
|
|
|
|
|
const parsed = new URL(candidate);
|
|
|
|
|
|
if (parsed.protocol !== "file:") {
|
|
|
|
|
|
return params.filePath;
|
|
|
|
|
|
}
|
|
|
|
|
|
candidate = decodeURIComponent(parsed.pathname || "");
|
|
|
|
|
|
if (!candidate.startsWith("/")) {
|
|
|
|
|
|
return params.filePath;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return params.filePath;
|
|
|
|
|
|
}
|
2026-02-22 21:33:46 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const normalizedCandidate = candidate.replace(/\\/g, "/");
|
|
|
|
|
|
if (normalizedCandidate === normalizedWorkdir) {
|
|
|
|
|
|
return path.resolve(params.root);
|
|
|
|
|
|
}
|
|
|
|
|
|
const prefix = `${normalizedWorkdir}/`;
|
|
|
|
|
|
if (!normalizedCandidate.startsWith(prefix)) {
|
|
|
|
|
|
return candidate;
|
|
|
|
|
|
}
|
|
|
|
|
|
const relative = normalizedCandidate.slice(prefix.length);
|
|
|
|
|
|
if (!relative) {
|
|
|
|
|
|
return path.resolve(params.root);
|
|
|
|
|
|
}
|
|
|
|
|
|
return path.resolve(params.root, ...relative.split("/").filter(Boolean));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 12:44:33 +08:00
|
|
|
|
export function resolveToolPathAgainstWorkspaceRoot(params: {
|
|
|
|
|
|
filePath: string;
|
|
|
|
|
|
root: string;
|
|
|
|
|
|
containerWorkdir?: string;
|
|
|
|
|
|
}): string {
|
|
|
|
|
|
const mapped = mapContainerPathToWorkspaceRoot(params);
|
|
|
|
|
|
const candidate = mapped.startsWith("@") ? mapped.slice(1) : mapped;
|
|
|
|
|
|
return path.isAbsolute(candidate)
|
|
|
|
|
|
? path.resolve(candidate)
|
|
|
|
|
|
: path.resolve(params.root, candidate || ".");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type MemoryFlushAppendOnlyWriteOptions = {
|
|
|
|
|
|
root: string;
|
|
|
|
|
|
relativePath: string;
|
|
|
|
|
|
containerWorkdir?: string;
|
|
|
|
|
|
sandbox?: {
|
|
|
|
|
|
root: string;
|
|
|
|
|
|
bridge: SandboxFsBridge;
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
async function readOptionalUtf8File(params: {
|
|
|
|
|
|
absolutePath: string;
|
|
|
|
|
|
relativePath: string;
|
|
|
|
|
|
sandbox?: MemoryFlushAppendOnlyWriteOptions["sandbox"];
|
|
|
|
|
|
signal?: AbortSignal;
|
|
|
|
|
|
}): Promise<string> {
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (params.sandbox) {
|
|
|
|
|
|
const stat = await params.sandbox.bridge.stat({
|
|
|
|
|
|
filePath: params.relativePath,
|
|
|
|
|
|
cwd: params.sandbox.root,
|
|
|
|
|
|
signal: params.signal,
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!stat) {
|
|
|
|
|
|
return "";
|
|
|
|
|
|
}
|
|
|
|
|
|
const buffer = await params.sandbox.bridge.readFile({
|
|
|
|
|
|
filePath: params.relativePath,
|
|
|
|
|
|
cwd: params.sandbox.root,
|
|
|
|
|
|
signal: params.signal,
|
|
|
|
|
|
});
|
|
|
|
|
|
return buffer.toString("utf-8");
|
|
|
|
|
|
}
|
|
|
|
|
|
return await fs.readFile(params.absolutePath, "utf-8");
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") {
|
|
|
|
|
|
return "";
|
|
|
|
|
|
}
|
|
|
|
|
|
throw error;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function appendMemoryFlushContent(params: {
|
|
|
|
|
|
absolutePath: string;
|
|
|
|
|
|
root: string;
|
|
|
|
|
|
relativePath: string;
|
|
|
|
|
|
content: string;
|
|
|
|
|
|
sandbox?: MemoryFlushAppendOnlyWriteOptions["sandbox"];
|
|
|
|
|
|
signal?: AbortSignal;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
if (!params.sandbox) {
|
|
|
|
|
|
await appendFileWithinRoot({
|
|
|
|
|
|
rootDir: params.root,
|
|
|
|
|
|
relativePath: params.relativePath,
|
|
|
|
|
|
data: params.content,
|
|
|
|
|
|
mkdir: true,
|
|
|
|
|
|
prependNewlineIfNeeded: true,
|
|
|
|
|
|
});
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const existing = await readOptionalUtf8File({
|
|
|
|
|
|
absolutePath: params.absolutePath,
|
|
|
|
|
|
relativePath: params.relativePath,
|
|
|
|
|
|
sandbox: params.sandbox,
|
|
|
|
|
|
signal: params.signal,
|
|
|
|
|
|
});
|
|
|
|
|
|
const separator =
|
|
|
|
|
|
existing.length > 0 && !existing.endsWith("\n") && !params.content.startsWith("\n") ? "\n" : "";
|
|
|
|
|
|
const next = `${existing}${separator}${params.content}`;
|
|
|
|
|
|
if (params.sandbox) {
|
|
|
|
|
|
const parent = path.posix.dirname(params.relativePath);
|
|
|
|
|
|
if (parent && parent !== ".") {
|
|
|
|
|
|
await params.sandbox.bridge.mkdirp({
|
|
|
|
|
|
filePath: parent,
|
|
|
|
|
|
cwd: params.sandbox.root,
|
|
|
|
|
|
signal: params.signal,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
await params.sandbox.bridge.writeFile({
|
|
|
|
|
|
filePath: params.relativePath,
|
|
|
|
|
|
cwd: params.sandbox.root,
|
|
|
|
|
|
data: next,
|
|
|
|
|
|
mkdir: true,
|
|
|
|
|
|
signal: params.signal,
|
|
|
|
|
|
});
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
await fs.mkdir(path.dirname(params.absolutePath), { recursive: true });
|
|
|
|
|
|
await fs.writeFile(params.absolutePath, next, "utf-8");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function wrapToolMemoryFlushAppendOnlyWrite(
|
|
|
|
|
|
tool: AnyAgentTool,
|
|
|
|
|
|
options: MemoryFlushAppendOnlyWriteOptions,
|
|
|
|
|
|
): AnyAgentTool {
|
|
|
|
|
|
const allowedAbsolutePath = path.resolve(options.root, options.relativePath);
|
|
|
|
|
|
return {
|
|
|
|
|
|
...tool,
|
|
|
|
|
|
description: `${tool.description} During memory flush, this tool may only append to ${options.relativePath}.`,
|
|
|
|
|
|
execute: async (toolCallId, args, signal, onUpdate) => {
|
|
|
|
|
|
const normalized = normalizeToolParams(args);
|
|
|
|
|
|
const record =
|
|
|
|
|
|
normalized ??
|
|
|
|
|
|
(args && typeof args === "object" ? (args as Record<string, unknown>) : undefined);
|
|
|
|
|
|
assertRequiredParams(record, CLAUDE_PARAM_GROUPS.write, tool.name);
|
|
|
|
|
|
const filePath =
|
|
|
|
|
|
typeof record?.path === "string" && record.path.trim() ? record.path : undefined;
|
|
|
|
|
|
const content = typeof record?.content === "string" ? record.content : undefined;
|
|
|
|
|
|
if (!filePath || content === undefined) {
|
|
|
|
|
|
return tool.execute(toolCallId, normalized ?? args, signal, onUpdate);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const resolvedPath = resolveToolPathAgainstWorkspaceRoot({
|
|
|
|
|
|
filePath,
|
|
|
|
|
|
root: options.root,
|
|
|
|
|
|
containerWorkdir: options.containerWorkdir,
|
|
|
|
|
|
});
|
|
|
|
|
|
if (resolvedPath !== allowedAbsolutePath) {
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
`Memory flush writes are restricted to ${options.relativePath}; use that path only.`,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await appendMemoryFlushContent({
|
|
|
|
|
|
absolutePath: allowedAbsolutePath,
|
|
|
|
|
|
root: options.root,
|
|
|
|
|
|
relativePath: options.relativePath,
|
|
|
|
|
|
content,
|
|
|
|
|
|
sandbox: options.sandbox,
|
|
|
|
|
|
signal,
|
|
|
|
|
|
});
|
|
|
|
|
|
return {
|
|
|
|
|
|
content: [{ type: "text", text: `Appended content to ${options.relativePath}.` }],
|
|
|
|
|
|
details: {
|
|
|
|
|
|
path: options.relativePath,
|
|
|
|
|
|
appendOnly: true,
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-22 21:33:46 +01:00
|
|
|
|
export function wrapToolWorkspaceRootGuardWithOptions(
|
|
|
|
|
|
tool: AnyAgentTool,
|
|
|
|
|
|
root: string,
|
|
|
|
|
|
options?: {
|
|
|
|
|
|
containerWorkdir?: string;
|
|
|
|
|
|
},
|
|
|
|
|
|
): AnyAgentTool {
|
2026-02-14 23:50:04 +01:00
|
|
|
|
return {
|
|
|
|
|
|
...tool,
|
|
|
|
|
|
execute: async (toolCallId, args, signal, onUpdate) => {
|
|
|
|
|
|
const normalized = normalizeToolParams(args);
|
|
|
|
|
|
const record =
|
|
|
|
|
|
normalized ??
|
|
|
|
|
|
(args && typeof args === "object" ? (args as Record<string, unknown>) : undefined);
|
|
|
|
|
|
const filePath = record?.path;
|
|
|
|
|
|
if (typeof filePath === "string" && filePath.trim()) {
|
2026-02-22 21:33:46 +01:00
|
|
|
|
const sandboxPath = mapContainerPathToWorkspaceRoot({
|
|
|
|
|
|
filePath,
|
|
|
|
|
|
root,
|
|
|
|
|
|
containerWorkdir: options?.containerWorkdir,
|
|
|
|
|
|
});
|
|
|
|
|
|
await assertSandboxPath({ filePath: sandboxPath, cwd: root, root });
|
2026-02-14 23:50:04 +01:00
|
|
|
|
}
|
|
|
|
|
|
return tool.execute(toolCallId, normalized ?? args, signal, onUpdate);
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 17:29:10 +02:00
|
|
|
|
type SandboxToolParams = {
|
|
|
|
|
|
root: string;
|
|
|
|
|
|
bridge: SandboxFsBridge;
|
fix(subagent): harden read-tool overflow guards and sticky reply threading (#19508)
* fix(gateway): avoid premature agent.wait completion on transient errors
* fix(agent): preemptively guard tool results against context overflow
* fix: harden tool-result context guard and add message_id metadata
* fix: use importOriginal in session-key mock to include DEFAULT_ACCOUNT_ID
The run.skill-filter test was mocking ../../routing/session-key.js with only
buildAgentMainSessionKey and normalizeAgentId, but the module also exports
DEFAULT_ACCOUNT_ID which is required transitively by src/web/auth-store.ts.
Switch to importOriginal pattern so all real exports are preserved alongside
the mocked functions.
* pi-runner: guard accumulated tool-result overflow in transformContext
* PI runner: compact overflowing tool-result context
* Subagent: harden tool-result context recovery
* Enhance tool-result context handling by adding support for legacy tool outputs and improving character estimation for message truncation. This includes a new function to create legacy tool results and updates to existing functions to better manage context overflow scenarios.
* Enhance iMessage handling by adding reply tag support in send functions and tests. This includes modifications to prepend or rewrite reply tags based on provided replyToId, ensuring proper message formatting for replies.
* Enhance message delivery across multiple channels by implementing sticky reply context for chunked messages. This includes preserving reply references in Discord, Telegram, and iMessage, ensuring that follow-up messages maintain their intended reply targets. Additionally, improve handling of reply tags in system prompts and tests to support consistent reply behavior.
* Enhance read tool functionality by implementing auto-paging across chunks when no explicit limit is provided, scaling output budget based on model context window. Additionally, add tests for adaptive reading behavior and capped continuation guidance for large outputs. Update related functions to support these features.
* Refine tool-result context management by stripping oversized read-tool details payloads during compaction, ensuring repeated read calls do not bypass context limits. Introduce new utility functions for handling truncation content and enhance character estimation for tool results. Add tests to validate the removal of excessive details in context overflow scenarios.
* Refine message delivery logic in Matrix and Telegram by introducing a flag to track if a text chunk was sent. This ensures that replies are only marked as delivered when a text chunk has been successfully sent, improving the accuracy of reply handling in both channels.
* fix: tighten reply threading coverage and prep fixes (#19508) (thanks @tyler6204)
2026-02-17 15:32:52 -08:00
|
|
|
|
modelContextWindowTokens?: number;
|
2026-02-18 00:43:31 +01:00
|
|
|
|
imageSanitization?: ImageSanitizationLimits;
|
2026-02-13 17:29:10 +02:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export function createSandboxedReadTool(params: SandboxToolParams) {
|
|
|
|
|
|
const base = createReadTool(params.root, {
|
|
|
|
|
|
operations: createSandboxReadOperations(params),
|
|
|
|
|
|
}) as unknown as AnyAgentTool;
|
fix(subagent): harden read-tool overflow guards and sticky reply threading (#19508)
* fix(gateway): avoid premature agent.wait completion on transient errors
* fix(agent): preemptively guard tool results against context overflow
* fix: harden tool-result context guard and add message_id metadata
* fix: use importOriginal in session-key mock to include DEFAULT_ACCOUNT_ID
The run.skill-filter test was mocking ../../routing/session-key.js with only
buildAgentMainSessionKey and normalizeAgentId, but the module also exports
DEFAULT_ACCOUNT_ID which is required transitively by src/web/auth-store.ts.
Switch to importOriginal pattern so all real exports are preserved alongside
the mocked functions.
* pi-runner: guard accumulated tool-result overflow in transformContext
* PI runner: compact overflowing tool-result context
* Subagent: harden tool-result context recovery
* Enhance tool-result context handling by adding support for legacy tool outputs and improving character estimation for message truncation. This includes a new function to create legacy tool results and updates to existing functions to better manage context overflow scenarios.
* Enhance iMessage handling by adding reply tag support in send functions and tests. This includes modifications to prepend or rewrite reply tags based on provided replyToId, ensuring proper message formatting for replies.
* Enhance message delivery across multiple channels by implementing sticky reply context for chunked messages. This includes preserving reply references in Discord, Telegram, and iMessage, ensuring that follow-up messages maintain their intended reply targets. Additionally, improve handling of reply tags in system prompts and tests to support consistent reply behavior.
* Enhance read tool functionality by implementing auto-paging across chunks when no explicit limit is provided, scaling output budget based on model context window. Additionally, add tests for adaptive reading behavior and capped continuation guidance for large outputs. Update related functions to support these features.
* Refine tool-result context management by stripping oversized read-tool details payloads during compaction, ensuring repeated read calls do not bypass context limits. Introduce new utility functions for handling truncation content and enhance character estimation for tool results. Add tests to validate the removal of excessive details in context overflow scenarios.
* Refine message delivery logic in Matrix and Telegram by introducing a flag to track if a text chunk was sent. This ensures that replies are only marked as delivered when a text chunk has been successfully sent, improving the accuracy of reply handling in both channels.
* fix: tighten reply threading coverage and prep fixes (#19508) (thanks @tyler6204)
2026-02-17 15:32:52 -08:00
|
|
|
|
return createOpenClawReadTool(base, {
|
|
|
|
|
|
modelContextWindowTokens: params.modelContextWindowTokens,
|
2026-02-18 00:43:31 +01:00
|
|
|
|
imageSanitization: params.imageSanitization,
|
fix(subagent): harden read-tool overflow guards and sticky reply threading (#19508)
* fix(gateway): avoid premature agent.wait completion on transient errors
* fix(agent): preemptively guard tool results against context overflow
* fix: harden tool-result context guard and add message_id metadata
* fix: use importOriginal in session-key mock to include DEFAULT_ACCOUNT_ID
The run.skill-filter test was mocking ../../routing/session-key.js with only
buildAgentMainSessionKey and normalizeAgentId, but the module also exports
DEFAULT_ACCOUNT_ID which is required transitively by src/web/auth-store.ts.
Switch to importOriginal pattern so all real exports are preserved alongside
the mocked functions.
* pi-runner: guard accumulated tool-result overflow in transformContext
* PI runner: compact overflowing tool-result context
* Subagent: harden tool-result context recovery
* Enhance tool-result context handling by adding support for legacy tool outputs and improving character estimation for message truncation. This includes a new function to create legacy tool results and updates to existing functions to better manage context overflow scenarios.
* Enhance iMessage handling by adding reply tag support in send functions and tests. This includes modifications to prepend or rewrite reply tags based on provided replyToId, ensuring proper message formatting for replies.
* Enhance message delivery across multiple channels by implementing sticky reply context for chunked messages. This includes preserving reply references in Discord, Telegram, and iMessage, ensuring that follow-up messages maintain their intended reply targets. Additionally, improve handling of reply tags in system prompts and tests to support consistent reply behavior.
* Enhance read tool functionality by implementing auto-paging across chunks when no explicit limit is provided, scaling output budget based on model context window. Additionally, add tests for adaptive reading behavior and capped continuation guidance for large outputs. Update related functions to support these features.
* Refine tool-result context management by stripping oversized read-tool details payloads during compaction, ensuring repeated read calls do not bypass context limits. Introduce new utility functions for handling truncation content and enhance character estimation for tool results. Add tests to validate the removal of excessive details in context overflow scenarios.
* Refine message delivery logic in Matrix and Telegram by introducing a flag to track if a text chunk was sent. This ensures that replies are only marked as delivered when a text chunk has been successfully sent, improving the accuracy of reply handling in both channels.
* fix: tighten reply threading coverage and prep fixes (#19508) (thanks @tyler6204)
2026-02-17 15:32:52 -08:00
|
|
|
|
});
|
2026-01-14 05:39:59 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 17:29:10 +02:00
|
|
|
|
export function createSandboxedWriteTool(params: SandboxToolParams) {
|
|
|
|
|
|
const base = createWriteTool(params.root, {
|
|
|
|
|
|
operations: createSandboxWriteOperations(params),
|
|
|
|
|
|
}) as unknown as AnyAgentTool;
|
2026-02-14 16:54:29 -08:00
|
|
|
|
return wrapToolParamNormalization(base, CLAUDE_PARAM_GROUPS.write);
|
2026-01-14 05:39:59 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 17:29:10 +02:00
|
|
|
|
export function createSandboxedEditTool(params: SandboxToolParams) {
|
|
|
|
|
|
const base = createEditTool(params.root, {
|
|
|
|
|
|
operations: createSandboxEditOperations(params),
|
|
|
|
|
|
}) as unknown as AnyAgentTool;
|
2026-02-14 16:54:29 -08:00
|
|
|
|
return wrapToolParamNormalization(base, CLAUDE_PARAM_GROUPS.edit);
|
2026-01-14 05:39:59 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-28 07:53:20 +08:00
|
|
|
|
export function createHostWorkspaceWriteTool(root: string, options?: { workspaceOnly?: boolean }) {
|
2026-02-26 13:32:02 +01:00
|
|
|
|
const base = createWriteTool(root, {
|
2026-02-28 07:53:20 +08:00
|
|
|
|
operations: createHostWriteOperations(root, options),
|
2026-02-26 13:32:02 +01:00
|
|
|
|
}) as unknown as AnyAgentTool;
|
|
|
|
|
|
return wrapToolParamNormalization(base, CLAUDE_PARAM_GROUPS.write);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-28 07:53:20 +08:00
|
|
|
|
export function createHostWorkspaceEditTool(root: string, options?: { workspaceOnly?: boolean }) {
|
2026-02-26 13:32:02 +01:00
|
|
|
|
const base = createEditTool(root, {
|
2026-02-28 07:53:20 +08:00
|
|
|
|
operations: createHostEditOperations(root, options),
|
2026-02-26 13:32:02 +01:00
|
|
|
|
}) as unknown as AnyAgentTool;
|
2026-03-03 09:30:57 +08:00
|
|
|
|
const withRecovery = wrapHostEditToolWithPostWriteRecovery(base, root);
|
|
|
|
|
|
return wrapToolParamNormalization(withRecovery, CLAUDE_PARAM_GROUPS.edit);
|
2026-02-26 13:32:02 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
fix(subagent): harden read-tool overflow guards and sticky reply threading (#19508)
* fix(gateway): avoid premature agent.wait completion on transient errors
* fix(agent): preemptively guard tool results against context overflow
* fix: harden tool-result context guard and add message_id metadata
* fix: use importOriginal in session-key mock to include DEFAULT_ACCOUNT_ID
The run.skill-filter test was mocking ../../routing/session-key.js with only
buildAgentMainSessionKey and normalizeAgentId, but the module also exports
DEFAULT_ACCOUNT_ID which is required transitively by src/web/auth-store.ts.
Switch to importOriginal pattern so all real exports are preserved alongside
the mocked functions.
* pi-runner: guard accumulated tool-result overflow in transformContext
* PI runner: compact overflowing tool-result context
* Subagent: harden tool-result context recovery
* Enhance tool-result context handling by adding support for legacy tool outputs and improving character estimation for message truncation. This includes a new function to create legacy tool results and updates to existing functions to better manage context overflow scenarios.
* Enhance iMessage handling by adding reply tag support in send functions and tests. This includes modifications to prepend or rewrite reply tags based on provided replyToId, ensuring proper message formatting for replies.
* Enhance message delivery across multiple channels by implementing sticky reply context for chunked messages. This includes preserving reply references in Discord, Telegram, and iMessage, ensuring that follow-up messages maintain their intended reply targets. Additionally, improve handling of reply tags in system prompts and tests to support consistent reply behavior.
* Enhance read tool functionality by implementing auto-paging across chunks when no explicit limit is provided, scaling output budget based on model context window. Additionally, add tests for adaptive reading behavior and capped continuation guidance for large outputs. Update related functions to support these features.
* Refine tool-result context management by stripping oversized read-tool details payloads during compaction, ensuring repeated read calls do not bypass context limits. Introduce new utility functions for handling truncation content and enhance character estimation for tool results. Add tests to validate the removal of excessive details in context overflow scenarios.
* Refine message delivery logic in Matrix and Telegram by introducing a flag to track if a text chunk was sent. This ensures that replies are only marked as delivered when a text chunk has been successfully sent, improving the accuracy of reply handling in both channels.
* fix: tighten reply threading coverage and prep fixes (#19508) (thanks @tyler6204)
2026-02-17 15:32:52 -08:00
|
|
|
|
export function createOpenClawReadTool(
|
|
|
|
|
|
base: AnyAgentTool,
|
|
|
|
|
|
options?: OpenClawReadToolOptions,
|
|
|
|
|
|
): AnyAgentTool {
|
2026-01-14 05:39:59 +00:00
|
|
|
|
const patched = patchToolSchemaForClaudeCompatibility(base);
|
|
|
|
|
|
return {
|
|
|
|
|
|
...patched,
|
|
|
|
|
|
execute: async (toolCallId, params, signal) => {
|
|
|
|
|
|
const normalized = normalizeToolParams(params);
|
|
|
|
|
|
const record =
|
|
|
|
|
|
normalized ??
|
2026-01-14 14:31:43 +00:00
|
|
|
|
(params && typeof params === "object" ? (params as Record<string, unknown>) : undefined);
|
2026-01-14 05:39:59 +00:00
|
|
|
|
assertRequiredParams(record, CLAUDE_PARAM_GROUPS.read, base.name);
|
fix(subagent): harden read-tool overflow guards and sticky reply threading (#19508)
* fix(gateway): avoid premature agent.wait completion on transient errors
* fix(agent): preemptively guard tool results against context overflow
* fix: harden tool-result context guard and add message_id metadata
* fix: use importOriginal in session-key mock to include DEFAULT_ACCOUNT_ID
The run.skill-filter test was mocking ../../routing/session-key.js with only
buildAgentMainSessionKey and normalizeAgentId, but the module also exports
DEFAULT_ACCOUNT_ID which is required transitively by src/web/auth-store.ts.
Switch to importOriginal pattern so all real exports are preserved alongside
the mocked functions.
* pi-runner: guard accumulated tool-result overflow in transformContext
* PI runner: compact overflowing tool-result context
* Subagent: harden tool-result context recovery
* Enhance tool-result context handling by adding support for legacy tool outputs and improving character estimation for message truncation. This includes a new function to create legacy tool results and updates to existing functions to better manage context overflow scenarios.
* Enhance iMessage handling by adding reply tag support in send functions and tests. This includes modifications to prepend or rewrite reply tags based on provided replyToId, ensuring proper message formatting for replies.
* Enhance message delivery across multiple channels by implementing sticky reply context for chunked messages. This includes preserving reply references in Discord, Telegram, and iMessage, ensuring that follow-up messages maintain their intended reply targets. Additionally, improve handling of reply tags in system prompts and tests to support consistent reply behavior.
* Enhance read tool functionality by implementing auto-paging across chunks when no explicit limit is provided, scaling output budget based on model context window. Additionally, add tests for adaptive reading behavior and capped continuation guidance for large outputs. Update related functions to support these features.
* Refine tool-result context management by stripping oversized read-tool details payloads during compaction, ensuring repeated read calls do not bypass context limits. Introduce new utility functions for handling truncation content and enhance character estimation for tool results. Add tests to validate the removal of excessive details in context overflow scenarios.
* Refine message delivery logic in Matrix and Telegram by introducing a flag to track if a text chunk was sent. This ensures that replies are only marked as delivered when a text chunk has been successfully sent, improving the accuracy of reply handling in both channels.
* fix: tighten reply threading coverage and prep fixes (#19508) (thanks @tyler6204)
2026-02-17 15:32:52 -08:00
|
|
|
|
const result = await executeReadWithAdaptivePaging({
|
|
|
|
|
|
base,
|
|
|
|
|
|
toolCallId,
|
|
|
|
|
|
args: (normalized ?? params ?? {}) as Record<string, unknown>,
|
|
|
|
|
|
signal,
|
|
|
|
|
|
maxBytes: resolveAdaptiveReadMaxBytes(options),
|
|
|
|
|
|
});
|
2026-01-14 14:31:43 +00:00
|
|
|
|
const filePath = typeof record?.path === "string" ? String(record.path) : "<unknown>";
|
fix(subagent): harden read-tool overflow guards and sticky reply threading (#19508)
* fix(gateway): avoid premature agent.wait completion on transient errors
* fix(agent): preemptively guard tool results against context overflow
* fix: harden tool-result context guard and add message_id metadata
* fix: use importOriginal in session-key mock to include DEFAULT_ACCOUNT_ID
The run.skill-filter test was mocking ../../routing/session-key.js with only
buildAgentMainSessionKey and normalizeAgentId, but the module also exports
DEFAULT_ACCOUNT_ID which is required transitively by src/web/auth-store.ts.
Switch to importOriginal pattern so all real exports are preserved alongside
the mocked functions.
* pi-runner: guard accumulated tool-result overflow in transformContext
* PI runner: compact overflowing tool-result context
* Subagent: harden tool-result context recovery
* Enhance tool-result context handling by adding support for legacy tool outputs and improving character estimation for message truncation. This includes a new function to create legacy tool results and updates to existing functions to better manage context overflow scenarios.
* Enhance iMessage handling by adding reply tag support in send functions and tests. This includes modifications to prepend or rewrite reply tags based on provided replyToId, ensuring proper message formatting for replies.
* Enhance message delivery across multiple channels by implementing sticky reply context for chunked messages. This includes preserving reply references in Discord, Telegram, and iMessage, ensuring that follow-up messages maintain their intended reply targets. Additionally, improve handling of reply tags in system prompts and tests to support consistent reply behavior.
* Enhance read tool functionality by implementing auto-paging across chunks when no explicit limit is provided, scaling output budget based on model context window. Additionally, add tests for adaptive reading behavior and capped continuation guidance for large outputs. Update related functions to support these features.
* Refine tool-result context management by stripping oversized read-tool details payloads during compaction, ensuring repeated read calls do not bypass context limits. Introduce new utility functions for handling truncation content and enhance character estimation for tool results. Add tests to validate the removal of excessive details in context overflow scenarios.
* Refine message delivery logic in Matrix and Telegram by introducing a flag to track if a text chunk was sent. This ensures that replies are only marked as delivered when a text chunk has been successfully sent, improving the accuracy of reply handling in both channels.
* fix: tighten reply threading coverage and prep fixes (#19508) (thanks @tyler6204)
2026-02-17 15:32:52 -08:00
|
|
|
|
const strippedDetailsResult = stripReadTruncationContentDetails(result);
|
|
|
|
|
|
const normalizedResult = await normalizeReadImageResult(strippedDetailsResult, filePath);
|
2026-02-18 00:43:31 +01:00
|
|
|
|
return sanitizeToolResultImages(
|
|
|
|
|
|
normalizedResult,
|
|
|
|
|
|
`read:${filePath}`,
|
|
|
|
|
|
options?.imageSanitization,
|
|
|
|
|
|
);
|
2026-01-14 05:39:59 +00:00
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-02-13 17:29:10 +02:00
|
|
|
|
|
|
|
|
|
|
function createSandboxReadOperations(params: SandboxToolParams) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
readFile: (absolutePath: string) =>
|
|
|
|
|
|
params.bridge.readFile({ filePath: absolutePath, cwd: params.root }),
|
|
|
|
|
|
access: async (absolutePath: string) => {
|
|
|
|
|
|
const stat = await params.bridge.stat({ filePath: absolutePath, cwd: params.root });
|
|
|
|
|
|
if (!stat) {
|
|
|
|
|
|
throw createFsAccessError("ENOENT", absolutePath);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
detectImageMimeType: async (absolutePath: string) => {
|
|
|
|
|
|
const buffer = await params.bridge.readFile({ filePath: absolutePath, cwd: params.root });
|
|
|
|
|
|
const mime = await detectMime({ buffer, filePath: absolutePath });
|
|
|
|
|
|
return mime && mime.startsWith("image/") ? mime : undefined;
|
|
|
|
|
|
},
|
|
|
|
|
|
} as const;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function createSandboxWriteOperations(params: SandboxToolParams) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
mkdir: async (dir: string) => {
|
|
|
|
|
|
await params.bridge.mkdirp({ filePath: dir, cwd: params.root });
|
|
|
|
|
|
},
|
|
|
|
|
|
writeFile: async (absolutePath: string, content: string) => {
|
|
|
|
|
|
await params.bridge.writeFile({ filePath: absolutePath, cwd: params.root, data: content });
|
|
|
|
|
|
},
|
|
|
|
|
|
} as const;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function createSandboxEditOperations(params: SandboxToolParams) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
readFile: (absolutePath: string) =>
|
|
|
|
|
|
params.bridge.readFile({ filePath: absolutePath, cwd: params.root }),
|
|
|
|
|
|
writeFile: (absolutePath: string, content: string) =>
|
|
|
|
|
|
params.bridge.writeFile({ filePath: absolutePath, cwd: params.root, data: content }),
|
|
|
|
|
|
access: async (absolutePath: string) => {
|
|
|
|
|
|
const stat = await params.bridge.stat({ filePath: absolutePath, cwd: params.root });
|
|
|
|
|
|
if (!stat) {
|
|
|
|
|
|
throw createFsAccessError("ENOENT", absolutePath);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
} as const;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 00:14:48 +00:00
|
|
|
|
async function writeHostFile(absolutePath: string, content: string) {
|
|
|
|
|
|
const resolved = path.resolve(absolutePath);
|
|
|
|
|
|
await fs.mkdir(path.dirname(resolved), { recursive: true });
|
|
|
|
|
|
await fs.writeFile(resolved, content, "utf-8");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-28 07:53:20 +08:00
|
|
|
|
function createHostWriteOperations(root: string, options?: { workspaceOnly?: boolean }) {
|
2026-03-02 01:43:50 +00:00
|
|
|
|
const workspaceOnly = options?.workspaceOnly ?? false;
|
2026-02-28 07:53:20 +08:00
|
|
|
|
|
|
|
|
|
|
if (!workspaceOnly) {
|
|
|
|
|
|
// When workspaceOnly is false, allow writes anywhere on the host
|
|
|
|
|
|
return {
|
|
|
|
|
|
mkdir: async (dir: string) => {
|
|
|
|
|
|
const resolved = path.resolve(dir);
|
|
|
|
|
|
await fs.mkdir(resolved, { recursive: true });
|
|
|
|
|
|
},
|
2026-03-03 00:14:48 +00:00
|
|
|
|
writeFile: writeHostFile,
|
2026-02-28 07:53:20 +08:00
|
|
|
|
} as const;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-02 01:43:50 +00:00
|
|
|
|
// When workspaceOnly is true, enforce workspace boundary
|
2026-02-26 13:32:02 +01:00
|
|
|
|
return {
|
|
|
|
|
|
mkdir: async (dir: string) => {
|
2026-03-02 04:04:02 +00:00
|
|
|
|
const relative = toRelativeWorkspacePath(root, dir, { allowRoot: true });
|
2026-02-26 13:32:02 +01:00
|
|
|
|
const resolved = relative ? path.resolve(root, relative) : path.resolve(root);
|
|
|
|
|
|
await assertSandboxPath({ filePath: resolved, cwd: root, root });
|
|
|
|
|
|
await fs.mkdir(resolved, { recursive: true });
|
|
|
|
|
|
},
|
|
|
|
|
|
writeFile: async (absolutePath: string, content: string) => {
|
2026-03-02 04:04:02 +00:00
|
|
|
|
const relative = toRelativeWorkspacePath(root, absolutePath);
|
2026-02-26 13:32:02 +01:00
|
|
|
|
await writeFileWithinRoot({
|
|
|
|
|
|
rootDir: root,
|
|
|
|
|
|
relativePath: relative,
|
|
|
|
|
|
data: content,
|
|
|
|
|
|
mkdir: true,
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
|
|
|
|
|
} as const;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-28 07:53:20 +08:00
|
|
|
|
function createHostEditOperations(root: string, options?: { workspaceOnly?: boolean }) {
|
2026-03-02 01:43:50 +00:00
|
|
|
|
const workspaceOnly = options?.workspaceOnly ?? false;
|
2026-02-28 07:53:20 +08:00
|
|
|
|
|
|
|
|
|
|
if (!workspaceOnly) {
|
|
|
|
|
|
// When workspaceOnly is false, allow edits anywhere on the host
|
|
|
|
|
|
return {
|
|
|
|
|
|
readFile: async (absolutePath: string) => {
|
|
|
|
|
|
const resolved = path.resolve(absolutePath);
|
|
|
|
|
|
return await fs.readFile(resolved);
|
|
|
|
|
|
},
|
2026-03-03 00:14:48 +00:00
|
|
|
|
writeFile: writeHostFile,
|
2026-02-28 07:53:20 +08:00
|
|
|
|
access: async (absolutePath: string) => {
|
|
|
|
|
|
const resolved = path.resolve(absolutePath);
|
|
|
|
|
|
await fs.access(resolved);
|
|
|
|
|
|
},
|
|
|
|
|
|
} as const;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-02 01:43:50 +00:00
|
|
|
|
// When workspaceOnly is true, enforce workspace boundary
|
2026-02-26 13:32:02 +01:00
|
|
|
|
return {
|
|
|
|
|
|
readFile: async (absolutePath: string) => {
|
2026-03-02 04:04:02 +00:00
|
|
|
|
const relative = toRelativeWorkspacePath(root, absolutePath);
|
2026-03-02 01:03:40 +00:00
|
|
|
|
const safeRead = await readFileWithinRoot({
|
2026-02-26 13:32:02 +01:00
|
|
|
|
rootDir: root,
|
|
|
|
|
|
relativePath: relative,
|
|
|
|
|
|
});
|
2026-03-02 01:03:40 +00:00
|
|
|
|
return safeRead.buffer;
|
2026-02-26 13:32:02 +01:00
|
|
|
|
},
|
|
|
|
|
|
writeFile: async (absolutePath: string, content: string) => {
|
2026-03-02 04:04:02 +00:00
|
|
|
|
const relative = toRelativeWorkspacePath(root, absolutePath);
|
2026-02-26 13:32:02 +01:00
|
|
|
|
await writeFileWithinRoot({
|
|
|
|
|
|
rootDir: root,
|
|
|
|
|
|
relativePath: relative,
|
|
|
|
|
|
data: content,
|
|
|
|
|
|
mkdir: true,
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
|
|
|
|
|
access: async (absolutePath: string) => {
|
2026-03-02 01:00:49 +00:00
|
|
|
|
let relative: string;
|
|
|
|
|
|
try {
|
2026-03-02 04:04:02 +00:00
|
|
|
|
relative = toRelativeWorkspacePath(root, absolutePath);
|
2026-03-02 01:00:49 +00:00
|
|
|
|
} catch {
|
|
|
|
|
|
// Path escapes workspace root. Don't throw here – the upstream
|
|
|
|
|
|
// library replaces any `access` error with a misleading "File not
|
|
|
|
|
|
// found" message. By returning silently the subsequent `readFile`
|
|
|
|
|
|
// call will throw the same "Path escapes workspace root" error
|
|
|
|
|
|
// through a code-path that propagates the original message.
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-02-26 13:32:02 +01:00
|
|
|
|
try {
|
|
|
|
|
|
const opened = await openFileWithinRoot({
|
|
|
|
|
|
rootDir: root,
|
|
|
|
|
|
relativePath: relative,
|
|
|
|
|
|
});
|
|
|
|
|
|
await opened.handle.close().catch(() => {});
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
if (error instanceof SafeOpenError && error.code === "not-found") {
|
|
|
|
|
|
throw createFsAccessError("ENOENT", absolutePath);
|
|
|
|
|
|
}
|
2026-02-28 20:37:36 +09:00
|
|
|
|
if (error instanceof SafeOpenError && error.code === "outside-workspace") {
|
2026-03-02 01:00:49 +00:00
|
|
|
|
// Don't throw here – see the comment above about the upstream
|
|
|
|
|
|
// library swallowing access errors as "File not found".
|
|
|
|
|
|
return;
|
2026-02-28 20:37:36 +09:00
|
|
|
|
}
|
2026-02-26 13:32:02 +01:00
|
|
|
|
throw error;
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
} as const;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 17:29:10 +02:00
|
|
|
|
function createFsAccessError(code: string, filePath: string): NodeJS.ErrnoException {
|
|
|
|
|
|
const error = new Error(`Sandbox FS error (${code}): ${filePath}`) as NodeJS.ErrnoException;
|
|
|
|
|
|
error.code = code;
|
|
|
|
|
|
return error;
|
|
|
|
|
|
}
|