refactor: align agent tool params with upstream pi

This commit is contained in:
Peter Steinberger 2026-04-05 20:36:40 +01:00
parent 181a50e146
commit ce1cd26fbc
No known key found for this signature in database
11 changed files with 162 additions and 405 deletions

View File

@ -503,7 +503,7 @@ if (sandboxRoot) {
- Refusal magic string scrubbing
- Turn validation for consecutive roles
- Claude Code parameter compatibility
- Strict upstream Pi tool parameter validation
### Google/Gemini

View File

@ -13,8 +13,8 @@ vi.mock("../logger.js", () => ({
}));
let toToolDefinitions: typeof import("./pi-tool-definition-adapter.js").toToolDefinitions;
let wrapToolParamNormalization: typeof import("./pi-tools.params.js").wrapToolParamNormalization;
let CLAUDE_PARAM_GROUPS: typeof import("./pi-tools.params.js").CLAUDE_PARAM_GROUPS;
let wrapToolParamValidation: typeof import("./pi-tools.params.js").wrapToolParamValidation;
let REQUIRED_PARAM_GROUPS: typeof import("./pi-tools.params.js").REQUIRED_PARAM_GROUPS;
let logError: typeof import("../logger.js").logError;
type ToolExecute = ReturnType<
@ -25,7 +25,7 @@ const extensionContext = {} as Parameters<ToolExecute>[4];
describe("pi tool definition adapter logging", () => {
beforeAll(async () => {
({ toToolDefinitions } = await import("./pi-tool-definition-adapter.js"));
({ wrapToolParamNormalization, CLAUDE_PARAM_GROUPS } = await import("./pi-tools.params.js"));
({ wrapToolParamValidation, REQUIRED_PARAM_GROUPS } = await import("./pi-tools.params.js"));
({ logError } = await import("../logger.js"));
});
@ -41,8 +41,12 @@ describe("pi tool definition adapter logging", () => {
description: "edits files",
parameters: Type.Object({
path: Type.String(),
oldText: Type.String(),
newText: Type.String(),
edits: Type.Array(
Type.Object({
oldText: Type.String(),
newText: Type.String(),
}),
),
}),
execute: async () => ({
content: [{ type: "text" as const, text: "ok" }],
@ -50,7 +54,7 @@ describe("pi tool definition adapter logging", () => {
}),
} satisfies AgentTool;
const tool = wrapToolParamNormalization(baseTool, CLAUDE_PARAM_GROUPS.edit);
const tool = wrapToolParamValidation(baseTool, REQUIRED_PARAM_GROUPS.edit);
const [def] = toToolDefinitions([tool]);
if (!def) {
throw new Error("missing tool definition");
@ -60,7 +64,7 @@ describe("pi tool definition adapter logging", () => {
expect(logError).toHaveBeenCalledWith(
expect.stringContaining(
'[tools] edit failed: Missing required parameters: oldText alias, newText alias (received: path). Supply correct parameters before retrying. raw_params={"path":"notes.txt"}',
'[tools] edit failed: Missing required parameter: edits (received: path). Supply correct parameters before retrying. raw_params={"path":"notes.txt"}',
),
);
});
@ -86,7 +90,7 @@ describe("pi tool definition adapter logging", () => {
execute,
} satisfies AgentTool;
const tool = wrapToolParamNormalization(baseTool, CLAUDE_PARAM_GROUPS.edit);
const tool = wrapToolParamValidation(baseTool, REQUIRED_PARAM_GROUPS.edit);
const [def] = toToolDefinitions([tool]);
if (!def) {
throw new Error("missing tool definition");

View File

@ -1,41 +1,12 @@
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox";
import { describe, expect, it, vi } from "vitest";
import {
patchToolSchemaForClaudeCompatibility,
wrapToolParamNormalization,
} from "./pi-tools.params.js";
import { REQUIRED_PARAM_GROUPS, wrapToolParamValidation } from "./pi-tools.params.js";
import { cleanToolSchemaForGemini } from "./pi-tools.schema.js";
describe("createOpenClawCodingTools", () => {
describe("Claude/Gemini alias support", () => {
it("adds Claude-style aliases to schemas without dropping metadata", () => {
const base: AgentTool = {
name: "write",
label: "write",
description: "test",
parameters: Type.Object({
path: Type.String({ description: "Path" }),
content: Type.String({ description: "Body" }),
}),
execute: vi.fn(),
};
const patched = patchToolSchemaForClaudeCompatibility(base);
const params = patched.parameters as {
additionalProperties?: unknown;
properties?: Record<string, unknown>;
required?: string[];
};
const props = params.properties ?? {};
expect(props.file_path).toEqual(props.path);
expect(params.additionalProperties).toBe(false);
expect(params.required ?? []).not.toContain("path");
expect(params.required ?? []).not.toContain("file_path");
});
it("normalizes file_path to path and enforces required groups at runtime", async () => {
describe("Gemini cleanup and strict param validation", () => {
it("enforces canonical path/content at runtime", async () => {
const execute = vi.fn(async (_id, args) => args);
const tool: AgentTool = {
name: "write",
@ -48,12 +19,9 @@ describe("createOpenClawCodingTools", () => {
execute,
};
const wrapped = wrapToolParamNormalization(tool, [
{ keys: ["path", "file_path"], label: "path (path or file_path)" },
{ keys: ["content"], label: "content" },
]);
const wrapped = wrapToolParamValidation(tool, REQUIRED_PARAM_GROUPS.write);
await wrapped.execute("tool-1", { file_path: "foo.txt", content: "x" });
await wrapped.execute("tool-1", { path: "foo.txt", content: "x" });
expect(execute).toHaveBeenCalledWith(
"tool-1",
{ path: "foo.txt", content: "x" },
@ -67,14 +35,14 @@ describe("createOpenClawCodingTools", () => {
await expect(wrapped.execute("tool-2", { content: "x" })).rejects.toThrow(
/Supply correct parameters before retrying\./,
);
await expect(wrapped.execute("tool-3", { file_path: " ", content: "x" })).rejects.toThrow(
await expect(wrapped.execute("tool-3", { path: " ", content: "x" })).rejects.toThrow(
/Missing required parameter/,
);
await expect(wrapped.execute("tool-3", { file_path: " ", content: "x" })).rejects.toThrow(
await expect(wrapped.execute("tool-3", { path: " ", content: "x" })).rejects.toThrow(
/Supply correct parameters before retrying\./,
);
await expect(wrapped.execute("tool-4", {})).rejects.toThrow(
/Missing required parameters: path \(path or file_path\), content/,
/Missing required parameters: path, content/,
);
await expect(wrapped.execute("tool-4", {})).rejects.toThrow(
/Supply correct parameters before retrying\./,

View File

@ -8,26 +8,25 @@ import { createOpenClawCodingTools } from "./pi-tools.js";
import { expectReadWriteEditTools } from "./test-helpers/pi-tools-fs-helpers.js";
describe("createOpenClawCodingTools", () => {
it("accepts Claude Code parameter aliases for read/write/edit", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-alias-"));
it("accepts canonical parameters for read/write/edit", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-canonical-"));
try {
const tools = createOpenClawCodingTools({ workspaceDir: tmpDir });
const { readTool, writeTool, editTool } = expectReadWriteEditTools(tools);
const filePath = "alias-test.txt";
await writeTool?.execute("tool-alias-1", {
file_path: filePath,
const filePath = "canonical-test.txt";
await writeTool?.execute("tool-canonical-1", {
path: filePath,
content: "hello world",
});
await editTool?.execute("tool-alias-2", {
file_path: filePath,
old_string: "world",
new_string: "universe",
await editTool?.execute("tool-canonical-2", {
path: filePath,
edits: [{ oldText: "world", newText: "universe" }],
});
const result = await readTool?.execute("tool-alias-3", {
file_path: filePath,
const result = await readTool?.execute("tool-canonical-3", {
path: filePath,
});
const textBlocks = result?.content?.filter((block) => block.type === "text") as
@ -40,62 +39,59 @@ describe("createOpenClawCodingTools", () => {
}
});
it("accepts broader file/edit alias variants", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-alias-broad-"));
it("rejects legacy alias parameters", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-legacy-alias-"));
try {
const tools = createOpenClawCodingTools({ workspaceDir: tmpDir });
const { readTool, writeTool, editTool } = expectReadWriteEditTools(tools);
await writeTool?.execute("tool-alias-broad-1", {
file: "broad-alias.txt",
content: "hello old value",
});
await expect(
writeTool?.execute("tool-legacy-write", {
file: "legacy.txt",
content: "hello old value",
}),
).rejects.toThrow(/Missing required parameter: path/);
await editTool?.execute("tool-alias-broad-2", {
filePath: "broad-alias.txt",
old_text: "old",
newString: "new",
});
await expect(
editTool?.execute("tool-legacy-edit", {
filePath: "legacy.txt",
old_text: "old",
newString: "new",
}),
).rejects.toThrow(/Missing required parameters: path, edits/);
const result = await readTool?.execute("tool-alias-broad-3", {
file: "broad-alias.txt",
});
const textBlocks = result?.content?.filter((block) => block.type === "text") as
| Array<{ text?: string }>
| undefined;
const combinedText = textBlocks?.map((block) => block.text ?? "").join("\n");
expect(combinedText).toContain("hello new value");
await expect(
readTool?.execute("tool-legacy-read", {
file_path: "legacy.txt",
}),
).rejects.toThrow(/Missing required parameter: path/);
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it("coerces structured content blocks for write", async () => {
it("rejects structured content blocks for write", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-structured-write-"));
try {
const tools = createOpenClawCodingTools({ workspaceDir: tmpDir });
const writeTool = tools.find((tool) => tool.name === "write");
expect(writeTool).toBeDefined();
await writeTool?.execute("tool-structured-write", {
path: "structured-write.js",
content: [
{ type: "text", text: "const path = require('path');\n" },
{ type: "input_text", text: "const root = path.join(process.env.HOME, 'clawd');\n" },
],
});
const written = await fs.readFile(path.join(tmpDir, "structured-write.js"), "utf8");
expect(written).toBe(
"const path = require('path');\nconst root = path.join(process.env.HOME, 'clawd');\n",
);
await expect(
writeTool?.execute("tool-structured-write", {
path: "structured-write.js",
content: [
{ type: "text", text: "const path = require('path');\n" },
{ type: "input_text", text: "const root = path.join(process.env.HOME, 'clawd');\n" },
],
}),
).rejects.toThrow(/Missing required parameter: content/);
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it("coerces structured old/new text blocks for edit", async () => {
it("rejects structured edit payloads", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-structured-edit-"));
try {
const filePath = path.join(tmpDir, "structured-edit.js");
@ -105,14 +101,17 @@ describe("createOpenClawCodingTools", () => {
const editTool = tools.find((tool) => tool.name === "edit");
expect(editTool).toBeDefined();
await editTool?.execute("tool-structured-edit", {
file_path: "structured-edit.js",
old_string: [{ type: "text", text: "old" }],
new_string: [{ kind: "text", value: "new" }],
});
const edited = await fs.readFile(filePath, "utf8");
expect(edited).toBe("const value = 'new';\n");
await expect(
editTool?.execute("tool-structured-edit", {
path: "structured-edit.js",
edits: [
{
oldText: [{ type: "text", text: "old" }],
newText: [{ kind: "text", value: "new" }],
},
],
}),
).rejects.toThrow(/Missing required parameter: edits/);
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}

View File

@ -27,7 +27,7 @@ function extractToolText(result: unknown): string {
}
describe("createOpenClawCodingTools read behavior", () => {
it("applies sandbox path guards to file_path alias", async () => {
it("applies sandbox path guards to canonical path", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sbx-"));
const outsidePath = path.join(os.tmpdir(), "openclaw-outside.txt");
await fs.writeFile(outsidePath, "outside", "utf8");
@ -36,7 +36,7 @@ describe("createOpenClawCodingTools read behavior", () => {
root: tmpDir,
bridge: createHostSandboxFsBridge(tmpDir),
});
await expect(readTool.execute("sandbox-1", { file_path: outsidePath })).rejects.toThrow(
await expect(readTool.execute("sandbox-1", { path: outsidePath })).rejects.toThrow(
/sandbox root/i,
);
} finally {

View File

@ -1,7 +1,7 @@
import os from "node:os";
import path from "node:path";
import type { AgentToolResult, AgentToolUpdateCallback } from "@mariozechner/pi-agent-core";
import { normalizeToolParams } from "./pi-tools.params.js";
import { getToolParamsRecord } from "./pi-tools.params.js";
import type { AnyAgentTool } from "./pi-tools.types.js";
type EditToolRecoveryOptions = {
@ -61,12 +61,9 @@ function readEditReplacements(record: Record<string, unknown> | undefined): Edit
}
function readEditToolParams(params: unknown): EditToolParams {
const normalized = normalizeToolParams(params);
const record =
normalized ??
(params && typeof params === "object" ? (params as Record<string, unknown>) : undefined);
const record = getToolParamsRecord(params);
return {
pathParam: readStringParam(record, "path", "file_path", "filePath", "file"),
pathParam: readStringParam(record, "path"),
edits: readEditReplacements(record),
};
}

View File

@ -1,46 +1,32 @@
import { Type } from "@sinclair/typebox";
import { describe, expect, it, vi } from "vitest";
import {
assertRequiredParams,
CLAUDE_PARAM_GROUPS,
patchToolSchemaForClaudeCompatibility,
wrapToolParamNormalization,
REQUIRED_PARAM_GROUPS,
getToolParamsRecord,
wrapToolParamValidation,
} from "./pi-tools.params.js";
describe("assertRequiredParams", () => {
it("patches Claude-compatible file tool schemas to disallow unknown parameters", () => {
const patched = patchToolSchemaForClaudeCompatibility({
name: "read",
label: "read",
description: "read a file",
parameters: Type.Object({
path: Type.String(),
offset: Type.Optional(Type.Number()),
limit: Type.Optional(Type.Number()),
}),
execute: vi.fn(),
});
expect((patched.parameters as { additionalProperties?: unknown }).additionalProperties).toBe(
false,
);
it("returns object params unchanged", () => {
const params = { path: "test.txt" };
expect(getToolParamsRecord(params)).toBe(params);
});
it("includes received keys in error when some params are present but content is missing", () => {
expect(() =>
assertRequiredParams(
{ file_path: "test.txt" },
{ path: "test.txt" },
[
{ keys: ["path", "file_path"], label: "path alias" },
{ keys: ["path"], label: "path" },
{ keys: ["content"], label: "content" },
],
"write",
),
).toThrow(/\(received: file_path\)/);
).toThrow(/\(received: path\)/);
});
it("shows normalized key in hint when called through wrapToolParamNormalization (file_path alias -> path)", async () => {
const tool = wrapToolParamNormalization(
it("does not normalize legacy aliases during validation", async () => {
const tool = wrapToolParamValidation(
{
name: "write",
label: "write",
@ -48,24 +34,24 @@ describe("assertRequiredParams", () => {
parameters: {},
execute: vi.fn(),
},
CLAUDE_PARAM_GROUPS.write,
REQUIRED_PARAM_GROUPS.write,
);
await expect(
tool.execute("id", { file_path: "test.txt" }, new AbortController().signal, vi.fn()),
).rejects.toThrow(/\(received: path\)/);
).rejects.toThrow(/\(received: file_path\)/);
});
it("excludes null and undefined values from received hint", () => {
expect(() =>
assertRequiredParams(
{ file_path: "test.txt", content: null },
{ path: "test.txt", content: null },
[
{ keys: ["path", "file_path"], label: "path alias" },
{ keys: ["path"], label: "path" },
{ keys: ["content"], label: "content" },
],
"write",
),
).toThrow(/\(received: file_path\)[^,]/);
).toThrow(/\(received: path\)[^,]/);
});
it("shows empty-string values for present params that still fail validation", () => {
@ -73,7 +59,7 @@ describe("assertRequiredParams", () => {
assertRequiredParams(
{ path: "/tmp/a.txt", content: " " },
[
{ keys: ["path", "file_path"], label: "path alias" },
{ keys: ["path"], label: "path" },
{ keys: ["content"], label: "content" },
],
"write",
@ -82,7 +68,7 @@ describe("assertRequiredParams", () => {
});
it("shows wrong-type values for present params that still fail validation", async () => {
const tool = wrapToolParamNormalization(
const tool = wrapToolParamValidation(
{
name: "write",
label: "write",
@ -90,12 +76,12 @@ describe("assertRequiredParams", () => {
parameters: {},
execute: vi.fn(),
},
CLAUDE_PARAM_GROUPS.write,
REQUIRED_PARAM_GROUPS.write,
);
await expect(
tool.execute(
"id",
{ file_path: "test.txt", content: { unexpected: true } },
{ path: "test.txt", content: { unexpected: true } },
new AbortController().signal,
vi.fn(),
),
@ -107,7 +93,7 @@ describe("assertRequiredParams", () => {
assertRequiredParams(
{ path: "/tmp/a.txt", extra: "yes" },
[
{ keys: ["path", "file_path"], label: "path alias" },
{ keys: ["path"], label: "path" },
{ keys: ["content"], label: "content" },
],
"write",
@ -133,7 +119,7 @@ describe("assertRequiredParams", () => {
assertRequiredParams(
{ path: "a.txt", content: "hello" },
[
{ keys: ["path", "file_path"], label: "path alias" },
{ keys: ["path"], label: "path" },
{ keys: ["content"], label: "content" },
],
"write",

View File

@ -46,141 +46,21 @@ function formatReceivedParamHint(
return received.length > 0 ? ` (received: ${received.join(", ")})` : "";
}
export const CLAUDE_PARAM_GROUPS = {
read: [{ keys: ["path", "file_path", "filePath", "file"], label: "path alias" }],
write: [
{ keys: ["path", "file_path", "filePath", "file"], label: "path alias" },
{ keys: ["content"], label: "content" },
],
edit: [
{ keys: ["path", "file_path", "filePath", "file"], label: "path alias" },
{
keys: ["oldText", "old_string", "old_text", "oldString"],
label: "oldText alias",
validator: hasValidEditReplacements,
},
{
keys: ["newText", "new_string", "new_text", "newString"],
label: "newText alias",
allowEmpty: true,
validator: hasValidEditReplacements,
},
],
} as const;
type ClaudeParamAlias = {
original: string;
alias: string;
};
const CLAUDE_PARAM_ALIASES: ClaudeParamAlias[] = [
{ original: "path", alias: "file_path" },
{ original: "path", alias: "filePath" },
{ original: "path", alias: "file" },
{ original: "oldText", alias: "old_string" },
{ original: "oldText", alias: "old_text" },
{ original: "oldText", alias: "oldString" },
{ original: "newText", alias: "new_string" },
{ original: "newText", alias: "new_text" },
{ original: "newText", alias: "newString" },
];
type EditReplacement = {
oldText: string;
newText: string;
};
function extractStructuredText(value: unknown, depth = 0): string | undefined {
if (depth > 6) {
return undefined;
}
if (typeof value === "string") {
return value;
}
if (Array.isArray(value)) {
const parts = value
.map((entry) => extractStructuredText(entry, depth + 1))
.filter((entry): entry is string => typeof entry === "string");
return parts.length > 0 ? parts.join("") : undefined;
}
function isValidEditReplacement(value: unknown): value is EditReplacement {
if (!value || typeof value !== "object") {
return undefined;
return false;
}
const record = value as Record<string, unknown>;
if (typeof record.text === "string") {
return record.text;
}
if (typeof record.content === "string") {
return record.content;
}
if (Array.isArray(record.content)) {
return extractStructuredText(record.content, depth + 1);
}
if (Array.isArray(record.parts)) {
return extractStructuredText(record.parts, depth + 1);
}
if (typeof record.value === "string" && record.value.length > 0) {
const type = typeof record.type === "string" ? record.type.toLowerCase() : "";
const kind = typeof record.kind === "string" ? record.kind.toLowerCase() : "";
if (type.includes("text") || kind === "text") {
return record.value;
}
}
return undefined;
}
function normalizeTextLikeParam(record: Record<string, unknown>, key: string) {
const value = record[key];
if (typeof value === "string") {
return;
}
const extracted = extractStructuredText(value);
if (typeof extracted === "string") {
record[key] = extracted;
}
}
function normalizeEditReplacement(value: unknown): EditReplacement | undefined {
if (!value || typeof value !== "object") {
return undefined;
}
const normalized = { ...(value as Record<string, unknown>) };
normalizeClaudeParamAliases(normalized);
normalizeTextLikeParam(normalized, "oldText");
normalizeTextLikeParam(normalized, "newText");
if (typeof normalized.oldText !== "string" || normalized.oldText.trim().length === 0) {
return undefined;
}
if (typeof normalized.newText !== "string") {
return undefined;
}
return {
oldText: normalized.oldText,
newText: normalized.newText,
};
}
function normalizeEditReplacements(record: Record<string, unknown>) {
const replacements: EditReplacement[] = [];
if (Array.isArray(record.edits)) {
for (const entry of record.edits) {
const normalized = normalizeEditReplacement(entry);
if (normalized) {
replacements.push(normalized);
}
}
}
if (typeof record.oldText === "string" && record.oldText.trim().length > 0) {
if (typeof record.newText === "string") {
replacements.push({
oldText: record.oldText,
newText: record.newText,
});
}
}
if (replacements.length > 0) {
record.edits = replacements;
}
return (
typeof record.oldText === "string" &&
record.oldText.trim().length > 0 &&
typeof record.newText === "string"
);
}
function hasValidEditReplacements(record: Record<string, unknown>): boolean {
@ -188,89 +68,24 @@ function hasValidEditReplacements(record: Record<string, unknown>): boolean {
return (
Array.isArray(edits) &&
edits.length > 0 &&
edits.every((entry) => normalizeEditReplacement(entry) !== undefined)
edits.every((entry) => isValidEditReplacement(entry))
);
}
function normalizeClaudeParamAliases(record: Record<string, unknown>) {
for (const { original, alias } of CLAUDE_PARAM_ALIASES) {
if (alias in record && !(original in record)) {
record[original] = record[alias];
}
delete record[alias];
}
}
export const REQUIRED_PARAM_GROUPS = {
read: [{ keys: ["path"], label: "path" }],
write: [
{ keys: ["path"], label: "path" },
{ keys: ["content"], label: "content" },
],
edit: [
{ keys: ["path"], label: "path" },
{ keys: ["edits"], label: "edits", validator: hasValidEditReplacements },
],
} as const;
function addClaudeParamAliasesToSchema(params: {
properties: Record<string, unknown>;
required: string[];
}): boolean {
let changed = false;
for (const { original, alias } of CLAUDE_PARAM_ALIASES) {
if (!(original in params.properties)) {
continue;
}
if (!(alias in params.properties)) {
params.properties[alias] = params.properties[original];
changed = true;
}
const idx = params.required.indexOf(original);
if (idx !== -1) {
params.required.splice(idx, 1);
changed = true;
}
}
return changed;
}
// Normalize tool parameters from Claude Code conventions to pi-coding-agent conventions.
// Claude Code uses file_path/old_string/new_string while pi-coding-agent uses path/oldText/newText.
// This prevents models trained on Claude Code from getting stuck in tool-call loops.
export function normalizeToolParams(params: unknown): Record<string, unknown> | undefined {
if (!params || typeof params !== "object") {
return undefined;
}
const record = params as Record<string, unknown>;
const normalized = { ...record };
normalizeClaudeParamAliases(normalized);
// Some providers/models emit text payloads as structured blocks instead of raw strings.
// Normalize these for write/edit so content matching and writes stay deterministic.
normalizeTextLikeParam(normalized, "content");
normalizeTextLikeParam(normalized, "oldText");
normalizeTextLikeParam(normalized, "newText");
normalizeEditReplacements(normalized);
return normalized;
}
export function patchToolSchemaForClaudeCompatibility(tool: AnyAgentTool): AnyAgentTool {
const schema =
tool.parameters && typeof tool.parameters === "object"
? (tool.parameters as Record<string, unknown>)
: undefined;
if (!schema || !schema.properties || typeof schema.properties !== "object") {
return tool;
}
const properties = { ...(schema.properties as Record<string, unknown>) };
const required = Array.isArray(schema.required)
? schema.required.filter((key): key is string => typeof key === "string")
: [];
const changed = addClaudeParamAliasesToSchema({ properties, required });
if (!changed) {
return tool;
}
return {
...tool,
parameters: {
...schema,
additionalProperties: "additionalProperties" in schema ? schema.additionalProperties : false,
properties,
required,
},
};
export function getToolParamsRecord(params: unknown): Record<string, unknown> | undefined {
return params && typeof params === "object" ? (params as Record<string, unknown>) : undefined;
}
export function assertRequiredParams(
@ -314,23 +129,18 @@ export function assertRequiredParams(
}
}
// Generic wrapper to normalize parameters for any tool.
export function wrapToolParamNormalization(
export function wrapToolParamValidation(
tool: AnyAgentTool,
requiredParamGroups?: readonly RequiredParamGroup[],
): AnyAgentTool {
const patched = patchToolSchemaForClaudeCompatibility(tool);
return {
...patched,
...tool,
execute: async (toolCallId, params, signal, onUpdate) => {
const normalized = normalizeToolParams(params);
const record =
normalized ??
(params && typeof params === "object" ? (params as Record<string, unknown>) : undefined);
const record = getToolParamsRecord(params);
if (requiredParamGroups?.length) {
assertRequiredParams(record, requiredParamGroups, tool.name);
}
return tool.execute(toolCallId, normalized ?? params, signal, onUpdate);
return tool.execute(toolCallId, params, signal, onUpdate);
},
};
}

View File

@ -103,7 +103,7 @@ describe("edit tool recovery hardening", () => {
await expect(
tool.execute(
"call-1",
{ path: filePath, oldText: "missing", newText: "replacement" },
{ path: filePath, edits: [{ oldText: "missing", newText: "replacement" }] },
undefined,
),
).rejects.toThrow(/Current file contents:\nactual current content/);
@ -126,8 +126,12 @@ describe("edit tool recovery hardening", () => {
"call-1",
{
path: filePath,
oldText: 'const value = "foo";\n',
newText: 'const value = "foobar";\n',
edits: [
{
oldText: 'const value = "foo";\n',
newText: 'const value = "foobar";\n',
},
],
},
undefined,
);
@ -154,7 +158,10 @@ describe("edit tool recovery hardening", () => {
await expect(
tool.execute(
"call-1",
{ path: filePath, oldText: "missing", newText: "replacement already present" },
{
path: filePath,
edits: [{ oldText: "missing", newText: "replacement already present" }],
},
undefined,
),
).rejects.toThrow("Simulated post-write failure");
@ -175,7 +182,7 @@ describe("edit tool recovery hardening", () => {
});
const result = await tool.execute(
"call-1",
{ path: filePath, oldText: "delete me", newText: "" },
{ path: filePath, edits: [{ oldText: "delete me", newText: "" }] },
undefined,
);
@ -235,7 +242,7 @@ describe("edit tool recovery hardening", () => {
});
const result = await tool.execute(
"call-1",
{ path: filePath, oldText: "old text", newText: "new text" },
{ path: filePath, edits: [{ oldText: "old text", newText: "new text" }] },
undefined,
);

View File

@ -16,11 +16,10 @@ import type { ImageSanitizationLimits } from "./image-sanitization.js";
import { toRelativeWorkspacePath } from "./path-policy.js";
import { wrapEditToolWithRecovery } from "./pi-tools.host-edit.js";
import {
CLAUDE_PARAM_GROUPS,
REQUIRED_PARAM_GROUPS,
assertRequiredParams,
normalizeToolParams,
patchToolSchemaForClaudeCompatibility,
wrapToolParamNormalization,
getToolParamsRecord,
wrapToolParamValidation,
} from "./pi-tools.params.js";
import type { AnyAgentTool } from "./pi-tools.types.js";
import { assertSandboxPath } from "./sandbox-paths.js";
@ -28,15 +27,14 @@ import type { SandboxFsBridge } from "./sandbox/fs-bridge.js";
import { sanitizeToolResultImages } from "./tool-images.js";
export {
CLAUDE_PARAM_GROUPS,
REQUIRED_PARAM_GROUPS,
assertRequiredParams,
normalizeToolParams,
patchToolSchemaForClaudeCompatibility,
wrapToolParamNormalization,
getToolParamsRecord,
wrapToolParamValidation,
} from "./pi-tools.params.js";
// 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.
// to 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" }>;
@ -509,16 +507,13 @@ export function wrapToolMemoryFlushAppendOnlyWrite(
...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 record = getToolParamsRecord(args);
assertRequiredParams(record, REQUIRED_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);
return tool.execute(toolCallId, args, signal, onUpdate);
}
const resolvedPath = resolveToolPathAgainstWorkspaceRoot({
@ -561,10 +556,7 @@ export function wrapToolWorkspaceRootGuardWithOptions(
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 record = getToolParamsRecord(args);
const filePath = record?.path;
if (typeof filePath === "string" && filePath.trim()) {
const sandboxPath = mapContainerPathToWorkspaceRoot({
@ -574,7 +566,7 @@ export function wrapToolWorkspaceRootGuardWithOptions(
});
await assertSandboxPath({ filePath: sandboxPath, cwd: root, root });
}
return tool.execute(toolCallId, normalized ?? args, signal, onUpdate);
return tool.execute(toolCallId, args, signal, onUpdate);
},
};
}
@ -600,7 +592,7 @@ export function createSandboxedWriteTool(params: SandboxToolParams) {
const base = createWriteTool(params.root, {
operations: createSandboxWriteOperations(params),
}) as unknown as AnyAgentTool;
return wrapToolParamNormalization(base, CLAUDE_PARAM_GROUPS.write);
return wrapToolParamValidation(base, REQUIRED_PARAM_GROUPS.write);
}
export function createSandboxedEditTool(params: SandboxToolParams) {
@ -612,14 +604,14 @@ export function createSandboxedEditTool(params: SandboxToolParams) {
readFile: async (absolutePath: string) =>
(await params.bridge.readFile({ filePath: absolutePath, cwd: params.root })).toString("utf8"),
});
return wrapToolParamNormalization(withRecovery, CLAUDE_PARAM_GROUPS.edit);
return wrapToolParamValidation(withRecovery, REQUIRED_PARAM_GROUPS.edit);
}
export function createHostWorkspaceWriteTool(root: string, options?: { workspaceOnly?: boolean }) {
const base = createWriteTool(root, {
operations: createHostWriteOperations(root, options),
}) as unknown as AnyAgentTool;
return wrapToolParamNormalization(base, CLAUDE_PARAM_GROUPS.write);
return wrapToolParamValidation(base, REQUIRED_PARAM_GROUPS.write);
}
export function createHostWorkspaceEditTool(root: string, options?: { workspaceOnly?: boolean }) {
@ -630,26 +622,22 @@ export function createHostWorkspaceEditTool(root: string, options?: { workspaceO
root,
readFile: (absolutePath: string) => fs.readFile(absolutePath, "utf-8"),
});
return wrapToolParamNormalization(withRecovery, CLAUDE_PARAM_GROUPS.edit);
return wrapToolParamValidation(withRecovery, REQUIRED_PARAM_GROUPS.edit);
}
export function createOpenClawReadTool(
base: AnyAgentTool,
options?: OpenClawReadToolOptions,
): AnyAgentTool {
const patched = patchToolSchemaForClaudeCompatibility(base);
return {
...patched,
...base,
execute: async (toolCallId, params, signal) => {
const normalized = normalizeToolParams(params);
const record =
normalized ??
(params && typeof params === "object" ? (params as Record<string, unknown>) : undefined);
assertRequiredParams(record, CLAUDE_PARAM_GROUPS.read, base.name);
const record = getToolParamsRecord(params);
assertRequiredParams(record, REQUIRED_PARAM_GROUPS.read, base.name);
const result = await executeReadWithAdaptivePaging({
base,
toolCallId,
args: (normalized ?? params ?? {}) as Record<string, unknown>,
args: record ?? {},
signal,
maxBytes: resolveAdaptiveReadMaxBytes(options),
});

View File

@ -38,12 +38,11 @@ import {
createSandboxedEditTool,
createSandboxedReadTool,
createSandboxedWriteTool,
normalizeToolParams,
patchToolSchemaForClaudeCompatibility,
getToolParamsRecord,
wrapToolMemoryFlushAppendOnlyWrite,
wrapToolWorkspaceRootGuard,
wrapToolWorkspaceRootGuardWithOptions,
wrapToolParamNormalization,
wrapToolParamValidation,
} from "./pi-tools.read.js";
import { cleanToolSchemaForGemini, normalizeToolParameters } from "./pi-tools.schema.js";
import type { AnyAgentTool } from "./pi-tools.types.js";
@ -234,9 +233,8 @@ export function resolveToolLoopDetectionConfig(params: {
export const __testing = {
cleanToolSchemaForGemini,
normalizeToolParams,
patchToolSchemaForClaudeCompatibility,
wrapToolParamNormalization,
getToolParamsRecord,
wrapToolParamValidation,
assertRequiredParams,
applyModelProviderToolPolicy,
} as const;