mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-11 09:11:13 +00:00
test: split legacy pi-tools schema shards
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyXaiModelCompat } from "../../extensions/xai/api.js";
|
||||
import {
|
||||
findUnsupportedSchemaKeywords,
|
||||
GEMINI_UNSUPPORTED_SCHEMA_KEYWORDS,
|
||||
XAI_UNSUPPORTED_SCHEMA_KEYWORDS,
|
||||
} from "../plugin-sdk/provider-tools.js";
|
||||
import "./test-helpers/fast-coding-tools.js";
|
||||
import "./test-helpers/fast-openclaw-tools.js";
|
||||
import { createOpenClawCodingTools } from "./pi-tools.js";
|
||||
|
||||
describe("createOpenClawCodingTools", () => {
|
||||
it("does not expose provider-specific message tools", () => {
|
||||
const tools = createOpenClawCodingTools({ messageProvider: "discord" });
|
||||
const names = new Set(tools.map((tool) => tool.name));
|
||||
expect(names.has("discord")).toBe(false);
|
||||
expect(names.has("slack")).toBe(false);
|
||||
expect(names.has("telegram")).toBe(false);
|
||||
expect(names.has("whatsapp")).toBe(false);
|
||||
});
|
||||
|
||||
it("filters session tools for sub-agent sessions by default", () => {
|
||||
const tools = createOpenClawCodingTools({
|
||||
sessionKey: "agent:main:subagent:test",
|
||||
});
|
||||
const names = new Set(tools.map((tool) => tool.name));
|
||||
expect(names.has("sessions_list")).toBe(false);
|
||||
expect(names.has("sessions_history")).toBe(false);
|
||||
expect(names.has("sessions_send")).toBe(false);
|
||||
expect(names.has("sessions_spawn")).toBe(false);
|
||||
expect(names.has("subagents")).toBe(false);
|
||||
|
||||
expect(names.has("read")).toBe(true);
|
||||
expect(names.has("exec")).toBe(true);
|
||||
expect(names.has("process")).toBe(true);
|
||||
expect(names.has("apply_patch")).toBe(false);
|
||||
});
|
||||
|
||||
it("uses stored spawnDepth to apply leaf tool policy for flat depth-2 session keys", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-depth-policy-"));
|
||||
const storeTemplate = path.join(tmpDir, "sessions-{agentId}.json");
|
||||
const storePath = storeTemplate.replaceAll("{agentId}", "main");
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:main:subagent:flat": {
|
||||
sessionId: "session-flat-depth-2",
|
||||
updatedAt: Date.now(),
|
||||
spawnDepth: 2,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const tools = createOpenClawCodingTools({
|
||||
sessionKey: "agent:main:subagent:flat",
|
||||
config: {
|
||||
session: {
|
||||
store: storeTemplate,
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
subagents: {
|
||||
maxSpawnDepth: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const names = new Set(tools.map((tool) => tool.name));
|
||||
expect(names.has("sessions_spawn")).toBe(false);
|
||||
expect(names.has("sessions_list")).toBe(false);
|
||||
expect(names.has("sessions_history")).toBe(false);
|
||||
expect(names.has("subagents")).toBe(false);
|
||||
});
|
||||
|
||||
it("supports allow-only sub-agent tool policy", () => {
|
||||
const tools = createOpenClawCodingTools({
|
||||
sessionKey: "agent:main:subagent:test",
|
||||
config: {
|
||||
tools: {
|
||||
subagents: {
|
||||
tools: {
|
||||
allow: ["read"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(tools.map((tool) => tool.name)).toEqual(["read"]);
|
||||
});
|
||||
|
||||
it("applies tool profiles before allow/deny policies", () => {
|
||||
const tools = createOpenClawCodingTools({
|
||||
config: { tools: { profile: "messaging" } },
|
||||
});
|
||||
const names = new Set(tools.map((tool) => tool.name));
|
||||
expect(names.has("message")).toBe(true);
|
||||
expect(names.has("sessions_send")).toBe(true);
|
||||
expect(names.has("sessions_spawn")).toBe(false);
|
||||
expect(names.has("exec")).toBe(false);
|
||||
expect(names.has("browser")).toBe(false);
|
||||
});
|
||||
|
||||
it("expands group shorthands in global tool policy", () => {
|
||||
const tools = createOpenClawCodingTools({
|
||||
config: { tools: { allow: ["group:fs"] } },
|
||||
});
|
||||
const names = new Set(tools.map((tool) => tool.name));
|
||||
expect(names.has("read")).toBe(true);
|
||||
expect(names.has("write")).toBe(true);
|
||||
expect(names.has("edit")).toBe(true);
|
||||
expect(names.has("exec")).toBe(false);
|
||||
expect(names.has("browser")).toBe(false);
|
||||
});
|
||||
|
||||
it("expands group shorthands in global tool deny policy", () => {
|
||||
const tools = createOpenClawCodingTools({
|
||||
config: { tools: { deny: ["group:fs"] } },
|
||||
});
|
||||
const names = new Set(tools.map((tool) => tool.name));
|
||||
expect(names.has("read")).toBe(false);
|
||||
expect(names.has("write")).toBe(false);
|
||||
expect(names.has("edit")).toBe(false);
|
||||
expect(names.has("exec")).toBe(true);
|
||||
});
|
||||
|
||||
it("lets agent profiles override global profiles", () => {
|
||||
const tools = createOpenClawCodingTools({
|
||||
sessionKey: "agent:work:main",
|
||||
config: {
|
||||
tools: { profile: "coding" },
|
||||
agents: {
|
||||
list: [{ id: "work", tools: { profile: "messaging" } }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const names = new Set(tools.map((tool) => tool.name));
|
||||
expect(names.has("message")).toBe(true);
|
||||
expect(names.has("exec")).toBe(false);
|
||||
expect(names.has("read")).toBe(false);
|
||||
});
|
||||
|
||||
it("removes unsupported JSON Schema keywords for Cloud Code Assist API compatibility", () => {
|
||||
const googleTools = createOpenClawCodingTools({
|
||||
modelProvider: "google",
|
||||
senderIsOwner: true,
|
||||
});
|
||||
for (const tool of googleTools) {
|
||||
const violations = findUnsupportedSchemaKeywords(
|
||||
tool.parameters,
|
||||
`${tool.name}.parameters`,
|
||||
GEMINI_UNSUPPORTED_SCHEMA_KEYWORDS,
|
||||
);
|
||||
expect(violations).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("applies xai model compat for direct Grok tool cleanup", () => {
|
||||
const xaiTools = createOpenClawCodingTools({
|
||||
modelProvider: "xai",
|
||||
modelCompat: applyXaiModelCompat({ compat: {} }).compat,
|
||||
senderIsOwner: true,
|
||||
});
|
||||
|
||||
expect(xaiTools.some((tool) => tool.name === "web_search")).toBe(false);
|
||||
for (const tool of xaiTools) {
|
||||
const violations = findUnsupportedSchemaKeywords(
|
||||
tool.parameters,
|
||||
`${tool.name}.parameters`,
|
||||
XAI_UNSUPPORTED_SCHEMA_KEYWORDS,
|
||||
);
|
||||
expect(
|
||||
violations.filter((violation) => {
|
||||
const keyword = violation.split(".").at(-1) ?? "";
|
||||
return XAI_UNSUPPORTED_SCHEMA_KEYWORDS.has(keyword);
|
||||
}),
|
||||
).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import "./test-helpers/fast-coding-tools.js";
|
||||
import "./test-helpers/fast-openclaw-tools.js";
|
||||
import { createOpenClawCodingTools } from "./pi-tools.js";
|
||||
import { createHostSandboxFsBridge } from "./test-helpers/host-sandbox-fs-bridge.js";
|
||||
import { createPiToolsSandboxContext } from "./test-helpers/pi-tools-sandbox-context.js";
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
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 { 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 () => {
|
||||
const execute = vi.fn(async (_id, args) => args);
|
||||
const tool: AgentTool = {
|
||||
name: "write",
|
||||
label: "write",
|
||||
description: "test",
|
||||
parameters: Type.Object({
|
||||
path: Type.String(),
|
||||
content: Type.String(),
|
||||
}),
|
||||
execute,
|
||||
};
|
||||
|
||||
const wrapped = wrapToolParamNormalization(tool, [
|
||||
{ keys: ["path", "file_path"], label: "path (path or file_path)" },
|
||||
{ keys: ["content"], label: "content" },
|
||||
]);
|
||||
|
||||
await wrapped.execute("tool-1", { file_path: "foo.txt", content: "x" });
|
||||
expect(execute).toHaveBeenCalledWith(
|
||||
"tool-1",
|
||||
{ path: "foo.txt", content: "x" },
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
|
||||
await expect(wrapped.execute("tool-2", { content: "x" })).rejects.toThrow(
|
||||
/Missing required parameter/,
|
||||
);
|
||||
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(
|
||||
/Missing required parameter/,
|
||||
);
|
||||
await expect(wrapped.execute("tool-3", { file_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/,
|
||||
);
|
||||
await expect(wrapped.execute("tool-4", {})).rejects.toThrow(
|
||||
/Supply correct parameters before retrying\./,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("inlines local $ref before removing unsupported keywords", () => {
|
||||
const cleaned = cleanToolSchemaForGemini({
|
||||
type: "object",
|
||||
properties: {
|
||||
foo: { $ref: "#/$defs/Foo" },
|
||||
},
|
||||
$defs: {
|
||||
Foo: { type: "string", enum: ["a", "b"] },
|
||||
},
|
||||
}) as {
|
||||
$defs?: unknown;
|
||||
properties?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
expect(cleaned.$defs).toBeUndefined();
|
||||
expect(cleaned.properties).toBeDefined();
|
||||
expect(cleaned.properties?.foo).toMatchObject({
|
||||
type: "string",
|
||||
enum: ["a", "b"],
|
||||
});
|
||||
});
|
||||
|
||||
it("cleans tuple items schemas", () => {
|
||||
const cleaned = cleanToolSchemaForGemini({
|
||||
type: "object",
|
||||
properties: {
|
||||
tuples: {
|
||||
type: "array",
|
||||
items: [
|
||||
{ type: "string", format: "uuid" },
|
||||
{ type: "number", minimum: 1 },
|
||||
],
|
||||
},
|
||||
},
|
||||
}) as {
|
||||
properties?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const tuples = cleaned.properties?.tuples as { items?: unknown } | undefined;
|
||||
const items = Array.isArray(tuples?.items) ? tuples?.items : [];
|
||||
const first = items[0] as { format?: unknown } | undefined;
|
||||
const second = items[1] as { minimum?: unknown } | undefined;
|
||||
|
||||
expect(first?.format).toBeUndefined();
|
||||
expect(second?.minimum).toBeUndefined();
|
||||
});
|
||||
|
||||
it("drops null-only union variants without flattening other unions", () => {
|
||||
const cleaned = cleanToolSchemaForGemini({
|
||||
type: "object",
|
||||
properties: {
|
||||
parentId: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||
count: { oneOf: [{ type: "string" }, { type: "number" }] },
|
||||
},
|
||||
}) as {
|
||||
properties?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const parentId = cleaned.properties?.parentId as
|
||||
| { type?: unknown; anyOf?: unknown; oneOf?: unknown }
|
||||
| undefined;
|
||||
const count = cleaned.properties?.count as
|
||||
| { type?: unknown; anyOf?: unknown; oneOf?: unknown }
|
||||
| undefined;
|
||||
|
||||
expect(parentId?.type).toBe("string");
|
||||
expect(parentId?.anyOf).toBeUndefined();
|
||||
expect(count?.oneOf).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import "./test-helpers/fast-coding-tools.js";
|
||||
import "./test-helpers/fast-openclaw-tools.js";
|
||||
import { createOpenClawCodingTools } from "./pi-tools.js";
|
||||
import { expectReadWriteEditTools } from "./test-helpers/pi-tools-fs-helpers.js";
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createOpenClawReadTool, createSandboxedReadTool } from "./pi-tools.read.js";
|
||||
import { createHostSandboxFsBridge } from "./test-helpers/host-sandbox-fs-bridge.js";
|
||||
|
||||
function extractToolText(result: unknown): string {
|
||||
if (!result || typeof result !== "object") {
|
||||
return "";
|
||||
}
|
||||
const content = (result as { content?: unknown }).content;
|
||||
if (!Array.isArray(content)) {
|
||||
return "";
|
||||
}
|
||||
const textBlock = content.find((block) => {
|
||||
return (
|
||||
block &&
|
||||
typeof block === "object" &&
|
||||
(block as { type?: unknown }).type === "text" &&
|
||||
typeof (block as { text?: unknown }).text === "string"
|
||||
);
|
||||
}) as { text?: string } | undefined;
|
||||
return textBlock?.text ?? "";
|
||||
}
|
||||
|
||||
describe("createOpenClawCodingTools read behavior", () => {
|
||||
it("applies sandbox path guards to file_path alias", 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");
|
||||
try {
|
||||
const readTool = createSandboxedReadTool({
|
||||
root: tmpDir,
|
||||
bridge: createHostSandboxFsBridge(tmpDir),
|
||||
});
|
||||
await expect(readTool.execute("sandbox-1", { file_path: outsidePath })).rejects.toThrow(
|
||||
/sandbox root/i,
|
||||
);
|
||||
} finally {
|
||||
await fs.rm(outsidePath, { force: true });
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("auto-pages read output across chunks when context window budget allows", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-read-autopage-"));
|
||||
const filePath = path.join(tmpDir, "big.txt");
|
||||
const lines = Array.from(
|
||||
{ length: 5000 },
|
||||
(_unused, i) => `line-${String(i + 1).padStart(4, "0")}`,
|
||||
);
|
||||
await fs.writeFile(filePath, lines.join("\n"), "utf8");
|
||||
try {
|
||||
const readTool = createSandboxedReadTool({
|
||||
root: tmpDir,
|
||||
bridge: createHostSandboxFsBridge(tmpDir),
|
||||
modelContextWindowTokens: 200_000,
|
||||
});
|
||||
const result = await readTool.execute("read-autopage-1", { path: "big.txt" });
|
||||
const text = extractToolText(result);
|
||||
expect(text).toContain("line-0001");
|
||||
expect(text).toContain("line-5000");
|
||||
expect(text).not.toContain("Read output capped at");
|
||||
expect(text).not.toMatch(/Use offset=\d+ to continue\.\]$/);
|
||||
} finally {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("adds capped continuation guidance when aggregated read output reaches budget", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-read-cap-"));
|
||||
const filePath = path.join(tmpDir, "huge.txt");
|
||||
const lines = Array.from(
|
||||
{ length: 8000 },
|
||||
(_unused, i) => `line-${String(i + 1).padStart(4, "0")}-abcdefghijklmnopqrstuvwxyz`,
|
||||
);
|
||||
await fs.writeFile(filePath, lines.join("\n"), "utf8");
|
||||
try {
|
||||
const readTool = createSandboxedReadTool({
|
||||
root: tmpDir,
|
||||
bridge: createHostSandboxFsBridge(tmpDir),
|
||||
});
|
||||
const result = await readTool.execute("read-cap-1", { path: "huge.txt" });
|
||||
const text = extractToolText(result);
|
||||
expect(text).toContain("line-0001");
|
||||
expect(text).toContain("[Read output capped at 50KB for this call. Use offset=");
|
||||
expect(text).not.toContain("line-8000");
|
||||
} finally {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("strips truncation.content details from read results while preserving other fields", async () => {
|
||||
const readResult: AgentToolResult<unknown> = {
|
||||
content: [{ type: "text" as const, text: "line-0001" }],
|
||||
details: {
|
||||
truncation: {
|
||||
truncated: true,
|
||||
outputLines: 1,
|
||||
firstLineExceedsLimit: false,
|
||||
content: "hidden duplicate payload",
|
||||
},
|
||||
},
|
||||
};
|
||||
const baseRead: AgentTool = {
|
||||
name: "read",
|
||||
label: "read",
|
||||
description: "test read",
|
||||
parameters: Type.Object({
|
||||
path: Type.String(),
|
||||
offset: Type.Optional(Type.Number()),
|
||||
limit: Type.Optional(Type.Number()),
|
||||
}),
|
||||
execute: vi.fn(async () => readResult),
|
||||
};
|
||||
|
||||
const wrapped = createOpenClawReadTool(
|
||||
baseRead as unknown as Parameters<typeof createOpenClawReadTool>[0],
|
||||
);
|
||||
const result = await wrapped.execute("read-strip-1", { path: "demo.txt", limit: 1 });
|
||||
|
||||
const details = (result as { details?: { truncation?: Record<string, unknown> } }).details;
|
||||
expect(details?.truncation).toMatchObject({
|
||||
truncated: true,
|
||||
outputLines: 1,
|
||||
firstLineExceedsLimit: false,
|
||||
});
|
||||
expect(details?.truncation).not.toHaveProperty("content");
|
||||
});
|
||||
});
|
||||
@@ -1,588 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createBrowserTool } from "../../extensions/browser/runtime-api.js";
|
||||
import { applyXaiModelCompat } from "../../extensions/xai/api.js";
|
||||
import {
|
||||
findUnsupportedSchemaKeywords,
|
||||
GEMINI_UNSUPPORTED_SCHEMA_KEYWORDS,
|
||||
XAI_UNSUPPORTED_SCHEMA_KEYWORDS,
|
||||
} from "../plugin-sdk/provider-tools.js";
|
||||
import "./test-helpers/fast-coding-tools.js";
|
||||
import { createOpenClawTools } from "./openclaw-tools.js";
|
||||
import { __testing, createOpenClawCodingTools } from "./pi-tools.js";
|
||||
import { createOpenClawReadTool, createSandboxedReadTool } from "./pi-tools.read.js";
|
||||
import { createHostSandboxFsBridge } from "./test-helpers/host-sandbox-fs-bridge.js";
|
||||
|
||||
const defaultTools = createOpenClawCodingTools();
|
||||
|
||||
function findUnionKeywordOffenders(
|
||||
tools: Array<{ name: string; parameters: unknown }>,
|
||||
opts?: { onlyNames?: Set<string> },
|
||||
) {
|
||||
const offenders: Array<{
|
||||
name: string;
|
||||
keyword: string;
|
||||
path: string;
|
||||
}> = [];
|
||||
const keywords = new Set(["anyOf", "oneOf", "allOf"]);
|
||||
|
||||
const walk = (value: unknown, path: string, name: string): void => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const [index, entry] of value.entries()) {
|
||||
walk(entry, `${path}[${index}]`, name);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (typeof value !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
for (const [key, entry] of Object.entries(record)) {
|
||||
const nextPath = path ? `${path}.${key}` : key;
|
||||
if (keywords.has(key)) {
|
||||
offenders.push({ name, keyword: key, path: nextPath });
|
||||
}
|
||||
walk(entry, nextPath, name);
|
||||
}
|
||||
};
|
||||
|
||||
for (const tool of tools) {
|
||||
if (opts?.onlyNames && !opts.onlyNames.has(tool.name)) {
|
||||
continue;
|
||||
}
|
||||
walk(tool.parameters, "", tool.name);
|
||||
}
|
||||
|
||||
return offenders;
|
||||
}
|
||||
|
||||
function extractToolText(result: unknown): string {
|
||||
if (!result || typeof result !== "object") {
|
||||
return "";
|
||||
}
|
||||
const content = (result as { content?: unknown }).content;
|
||||
if (!Array.isArray(content)) {
|
||||
return "";
|
||||
}
|
||||
const textBlock = content.find((block) => {
|
||||
return (
|
||||
block &&
|
||||
typeof block === "object" &&
|
||||
(block as { type?: unknown }).type === "text" &&
|
||||
typeof (block as { text?: unknown }).text === "string"
|
||||
);
|
||||
}) as { text?: string } | undefined;
|
||||
return textBlock?.text ?? "";
|
||||
}
|
||||
|
||||
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 = __testing.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 () => {
|
||||
const execute = vi.fn(async (_id, args) => args);
|
||||
const tool: AgentTool = {
|
||||
name: "write",
|
||||
label: "write",
|
||||
description: "test",
|
||||
parameters: Type.Object({
|
||||
path: Type.String(),
|
||||
content: Type.String(),
|
||||
}),
|
||||
execute,
|
||||
};
|
||||
|
||||
const wrapped = __testing.wrapToolParamNormalization(tool, [
|
||||
{ keys: ["path", "file_path"], label: "path (path or file_path)" },
|
||||
{ keys: ["content"], label: "content" },
|
||||
]);
|
||||
|
||||
await wrapped.execute("tool-1", { file_path: "foo.txt", content: "x" });
|
||||
expect(execute).toHaveBeenCalledWith(
|
||||
"tool-1",
|
||||
{ path: "foo.txt", content: "x" },
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
|
||||
await expect(wrapped.execute("tool-2", { content: "x" })).rejects.toThrow(
|
||||
/Missing required parameter/,
|
||||
);
|
||||
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(
|
||||
/Missing required parameter/,
|
||||
);
|
||||
await expect(wrapped.execute("tool-3", { file_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/,
|
||||
);
|
||||
await expect(wrapped.execute("tool-4", {})).rejects.toThrow(
|
||||
/Supply correct parameters before retrying\./,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps browser tool schema OpenAI-compatible without normalization", () => {
|
||||
const browser = createBrowserTool();
|
||||
const schema = browser.parameters as { type?: unknown; anyOf?: unknown };
|
||||
expect(schema.type).toBe("object");
|
||||
expect(schema.anyOf).toBeUndefined();
|
||||
});
|
||||
it("mentions user browser profile in browser tool description", () => {
|
||||
const browser = createBrowserTool();
|
||||
expect(browser.description).toMatch(/profile="user"/i);
|
||||
});
|
||||
it("keeps browser tool schema properties after normalization", () => {
|
||||
const browser = createBrowserTool();
|
||||
const parameters = browser.parameters as {
|
||||
anyOf?: unknown[];
|
||||
properties?: Record<string, unknown>;
|
||||
required?: string[];
|
||||
};
|
||||
expect(parameters.properties?.action).toBeDefined();
|
||||
expect(parameters.properties?.target).toBeDefined();
|
||||
expect(parameters.properties?.targetUrl).toBeDefined();
|
||||
expect(parameters.properties?.request).toBeDefined();
|
||||
expect(parameters.required ?? []).toContain("action");
|
||||
});
|
||||
it("exposes raw for gateway config.apply tool calls", () => {
|
||||
const gateway = createOpenClawCodingTools({ senderIsOwner: true }).find(
|
||||
(tool) => tool.name === "gateway",
|
||||
);
|
||||
expect(gateway).toBeDefined();
|
||||
|
||||
const parameters = gateway?.parameters as {
|
||||
type?: unknown;
|
||||
required?: string[];
|
||||
properties?: Record<string, unknown>;
|
||||
};
|
||||
expect(parameters.type).toBe("object");
|
||||
expect(parameters.properties?.raw).toBeDefined();
|
||||
expect(parameters.required ?? []).not.toContain("raw");
|
||||
});
|
||||
it("flattens anyOf-of-literals to enum for provider compatibility", () => {
|
||||
const browser = createBrowserTool();
|
||||
const parameters = browser.parameters as {
|
||||
properties?: Record<string, unknown>;
|
||||
};
|
||||
const action = parameters.properties?.action as
|
||||
| {
|
||||
type?: unknown;
|
||||
enum?: unknown[];
|
||||
anyOf?: unknown[];
|
||||
}
|
||||
| undefined;
|
||||
|
||||
expect(action?.type).toBe("string");
|
||||
expect(action?.anyOf).toBeUndefined();
|
||||
expect(Array.isArray(action?.enum)).toBe(true);
|
||||
expect(action?.enum).toContain("act");
|
||||
|
||||
const snapshotFormat = parameters.properties?.snapshotFormat as
|
||||
| {
|
||||
type?: unknown;
|
||||
enum?: unknown[];
|
||||
anyOf?: unknown[];
|
||||
}
|
||||
| undefined;
|
||||
expect(snapshotFormat?.type).toBe("string");
|
||||
expect(snapshotFormat?.anyOf).toBeUndefined();
|
||||
expect(snapshotFormat?.enum).toEqual(["aria", "ai"]);
|
||||
});
|
||||
it("inlines local $ref before removing unsupported keywords", () => {
|
||||
const cleaned = __testing.cleanToolSchemaForGemini({
|
||||
type: "object",
|
||||
properties: {
|
||||
foo: { $ref: "#/$defs/Foo" },
|
||||
},
|
||||
$defs: {
|
||||
Foo: { type: "string", enum: ["a", "b"] },
|
||||
},
|
||||
}) as {
|
||||
$defs?: unknown;
|
||||
properties?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
expect(cleaned.$defs).toBeUndefined();
|
||||
expect(cleaned.properties).toBeDefined();
|
||||
expect(cleaned.properties?.foo).toMatchObject({
|
||||
type: "string",
|
||||
enum: ["a", "b"],
|
||||
});
|
||||
});
|
||||
it("cleans tuple items schemas", () => {
|
||||
const cleaned = __testing.cleanToolSchemaForGemini({
|
||||
type: "object",
|
||||
properties: {
|
||||
tuples: {
|
||||
type: "array",
|
||||
items: [
|
||||
{ type: "string", format: "uuid" },
|
||||
{ type: "number", minimum: 1 },
|
||||
],
|
||||
},
|
||||
},
|
||||
}) as {
|
||||
properties?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const tuples = cleaned.properties?.tuples as { items?: unknown } | undefined;
|
||||
const items = Array.isArray(tuples?.items) ? tuples?.items : [];
|
||||
const first = items[0] as { format?: unknown } | undefined;
|
||||
const second = items[1] as { minimum?: unknown } | undefined;
|
||||
|
||||
expect(first?.format).toBeUndefined();
|
||||
expect(second?.minimum).toBeUndefined();
|
||||
});
|
||||
it("drops null-only union variants without flattening other unions", () => {
|
||||
const cleaned = __testing.cleanToolSchemaForGemini({
|
||||
type: "object",
|
||||
properties: {
|
||||
parentId: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||
count: { oneOf: [{ type: "string" }, { type: "number" }] },
|
||||
},
|
||||
}) as {
|
||||
properties?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const parentId = cleaned.properties?.parentId as
|
||||
| { type?: unknown; anyOf?: unknown; oneOf?: unknown }
|
||||
| undefined;
|
||||
const count = cleaned.properties?.count as
|
||||
| { type?: unknown; anyOf?: unknown; oneOf?: unknown }
|
||||
| undefined;
|
||||
|
||||
expect(parentId?.type).toBe("string");
|
||||
expect(parentId?.anyOf).toBeUndefined();
|
||||
expect(count?.oneOf).toBeUndefined();
|
||||
});
|
||||
it("avoids anyOf/oneOf/allOf in tool schemas", () => {
|
||||
expect(findUnionKeywordOffenders(defaultTools)).toEqual([]);
|
||||
});
|
||||
it("keeps raw core tool schemas union-free", () => {
|
||||
const tools = createOpenClawTools();
|
||||
const coreTools = new Set([
|
||||
"browser",
|
||||
"canvas",
|
||||
"nodes",
|
||||
"cron",
|
||||
"message",
|
||||
"gateway",
|
||||
"agents_list",
|
||||
"sessions_list",
|
||||
"sessions_history",
|
||||
"sessions_send",
|
||||
"sessions_spawn",
|
||||
"subagents",
|
||||
"session_status",
|
||||
"image",
|
||||
]);
|
||||
expect(findUnionKeywordOffenders(tools, { onlyNames: coreTools })).toEqual([]);
|
||||
});
|
||||
it("does not expose provider-specific message tools", () => {
|
||||
const tools = createOpenClawCodingTools({ messageProvider: "discord" });
|
||||
const names = new Set(tools.map((tool) => tool.name));
|
||||
expect(names.has("discord")).toBe(false);
|
||||
expect(names.has("slack")).toBe(false);
|
||||
expect(names.has("telegram")).toBe(false);
|
||||
expect(names.has("whatsapp")).toBe(false);
|
||||
});
|
||||
it("filters session tools for sub-agent sessions by default", () => {
|
||||
const tools = createOpenClawCodingTools({
|
||||
sessionKey: "agent:main:subagent:test",
|
||||
});
|
||||
const names = new Set(tools.map((tool) => tool.name));
|
||||
expect(names.has("sessions_list")).toBe(false);
|
||||
expect(names.has("sessions_history")).toBe(false);
|
||||
expect(names.has("sessions_send")).toBe(false);
|
||||
expect(names.has("sessions_spawn")).toBe(false);
|
||||
expect(names.has("subagents")).toBe(false);
|
||||
|
||||
expect(names.has("read")).toBe(true);
|
||||
expect(names.has("exec")).toBe(true);
|
||||
expect(names.has("process")).toBe(true);
|
||||
expect(names.has("apply_patch")).toBe(false);
|
||||
});
|
||||
|
||||
it("uses stored spawnDepth to apply leaf tool policy for flat depth-2 session keys", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-depth-policy-"));
|
||||
const storeTemplate = path.join(tmpDir, "sessions-{agentId}.json");
|
||||
const storePath = storeTemplate.replaceAll("{agentId}", "main");
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:main:subagent:flat": {
|
||||
sessionId: "session-flat-depth-2",
|
||||
updatedAt: Date.now(),
|
||||
spawnDepth: 2,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const tools = createOpenClawCodingTools({
|
||||
sessionKey: "agent:main:subagent:flat",
|
||||
config: {
|
||||
session: {
|
||||
store: storeTemplate,
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
subagents: {
|
||||
maxSpawnDepth: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const names = new Set(tools.map((tool) => tool.name));
|
||||
expect(names.has("sessions_spawn")).toBe(false);
|
||||
expect(names.has("sessions_list")).toBe(false);
|
||||
expect(names.has("sessions_history")).toBe(false);
|
||||
expect(names.has("subagents")).toBe(false);
|
||||
});
|
||||
it("supports allow-only sub-agent tool policy", () => {
|
||||
const tools = createOpenClawCodingTools({
|
||||
sessionKey: "agent:main:subagent:test",
|
||||
// Intentionally partial config; only fields used by pi-tools are provided.
|
||||
config: {
|
||||
tools: {
|
||||
subagents: {
|
||||
tools: {
|
||||
// Policy matching is case-insensitive
|
||||
allow: ["read"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(tools.map((tool) => tool.name)).toEqual(["read"]);
|
||||
});
|
||||
|
||||
it("applies tool profiles before allow/deny policies", () => {
|
||||
const tools = createOpenClawCodingTools({
|
||||
config: { tools: { profile: "messaging" } },
|
||||
});
|
||||
const names = new Set(tools.map((tool) => tool.name));
|
||||
expect(names.has("message")).toBe(true);
|
||||
expect(names.has("sessions_send")).toBe(true);
|
||||
expect(names.has("sessions_spawn")).toBe(false);
|
||||
expect(names.has("exec")).toBe(false);
|
||||
expect(names.has("browser")).toBe(false);
|
||||
});
|
||||
it("expands group shorthands in global tool policy", () => {
|
||||
const tools = createOpenClawCodingTools({
|
||||
config: { tools: { allow: ["group:fs"] } },
|
||||
});
|
||||
const names = new Set(tools.map((tool) => tool.name));
|
||||
expect(names.has("read")).toBe(true);
|
||||
expect(names.has("write")).toBe(true);
|
||||
expect(names.has("edit")).toBe(true);
|
||||
expect(names.has("exec")).toBe(false);
|
||||
expect(names.has("browser")).toBe(false);
|
||||
});
|
||||
it("expands group shorthands in global tool deny policy", () => {
|
||||
const tools = createOpenClawCodingTools({
|
||||
config: { tools: { deny: ["group:fs"] } },
|
||||
});
|
||||
const names = new Set(tools.map((tool) => tool.name));
|
||||
expect(names.has("read")).toBe(false);
|
||||
expect(names.has("write")).toBe(false);
|
||||
expect(names.has("edit")).toBe(false);
|
||||
expect(names.has("exec")).toBe(true);
|
||||
});
|
||||
it("lets agent profiles override global profiles", () => {
|
||||
const tools = createOpenClawCodingTools({
|
||||
sessionKey: "agent:work:main",
|
||||
config: {
|
||||
tools: { profile: "coding" },
|
||||
agents: {
|
||||
list: [{ id: "work", tools: { profile: "messaging" } }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const names = new Set(tools.map((tool) => tool.name));
|
||||
expect(names.has("message")).toBe(true);
|
||||
expect(names.has("exec")).toBe(false);
|
||||
expect(names.has("read")).toBe(false);
|
||||
});
|
||||
it("removes unsupported JSON Schema keywords for Cloud Code Assist API compatibility", () => {
|
||||
const googleTools = createOpenClawCodingTools({
|
||||
modelProvider: "google",
|
||||
senderIsOwner: true,
|
||||
});
|
||||
for (const tool of googleTools) {
|
||||
const violations = findUnsupportedSchemaKeywords(
|
||||
tool.parameters,
|
||||
`${tool.name}.parameters`,
|
||||
GEMINI_UNSUPPORTED_SCHEMA_KEYWORDS,
|
||||
);
|
||||
expect(violations).toEqual([]);
|
||||
}
|
||||
});
|
||||
it("applies xai model compat for direct Grok tool cleanup", () => {
|
||||
const xaiTools = createOpenClawCodingTools({
|
||||
modelProvider: "xai",
|
||||
modelCompat: applyXaiModelCompat({ compat: {} }).compat,
|
||||
senderIsOwner: true,
|
||||
});
|
||||
|
||||
expect(xaiTools.some((tool) => tool.name === "web_search")).toBe(false);
|
||||
for (const tool of xaiTools) {
|
||||
const violations = findUnsupportedSchemaKeywords(
|
||||
tool.parameters,
|
||||
`${tool.name}.parameters`,
|
||||
XAI_UNSUPPORTED_SCHEMA_KEYWORDS,
|
||||
);
|
||||
expect(
|
||||
violations.filter((violation) => {
|
||||
const keyword = violation.split(".").at(-1) ?? "";
|
||||
return XAI_UNSUPPORTED_SCHEMA_KEYWORDS.has(keyword);
|
||||
}),
|
||||
).toEqual([]);
|
||||
}
|
||||
});
|
||||
it("applies sandbox path guards to file_path alias", 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");
|
||||
try {
|
||||
const readTool = createSandboxedReadTool({
|
||||
root: tmpDir,
|
||||
bridge: createHostSandboxFsBridge(tmpDir),
|
||||
});
|
||||
await expect(readTool.execute("sandbox-1", { file_path: outsidePath })).rejects.toThrow(
|
||||
/sandbox root/i,
|
||||
);
|
||||
} finally {
|
||||
await fs.rm(outsidePath, { force: true });
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("auto-pages read output across chunks when context window budget allows", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-read-autopage-"));
|
||||
const filePath = path.join(tmpDir, "big.txt");
|
||||
const lines = Array.from(
|
||||
{ length: 5000 },
|
||||
(_unused, i) => `line-${String(i + 1).padStart(4, "0")}`,
|
||||
);
|
||||
await fs.writeFile(filePath, lines.join("\n"), "utf8");
|
||||
try {
|
||||
const readTool = createSandboxedReadTool({
|
||||
root: tmpDir,
|
||||
bridge: createHostSandboxFsBridge(tmpDir),
|
||||
modelContextWindowTokens: 200_000,
|
||||
});
|
||||
const result = await readTool.execute("read-autopage-1", { path: "big.txt" });
|
||||
const text = extractToolText(result);
|
||||
expect(text).toContain("line-0001");
|
||||
expect(text).toContain("line-5000");
|
||||
expect(text).not.toContain("Read output capped at");
|
||||
expect(text).not.toMatch(/Use offset=\d+ to continue\.\]$/);
|
||||
} finally {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("adds capped continuation guidance when aggregated read output reaches budget", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-read-cap-"));
|
||||
const filePath = path.join(tmpDir, "huge.txt");
|
||||
const lines = Array.from(
|
||||
{ length: 8000 },
|
||||
(_unused, i) => `line-${String(i + 1).padStart(4, "0")}-abcdefghijklmnopqrstuvwxyz`,
|
||||
);
|
||||
await fs.writeFile(filePath, lines.join("\n"), "utf8");
|
||||
try {
|
||||
const readTool = createSandboxedReadTool({
|
||||
root: tmpDir,
|
||||
bridge: createHostSandboxFsBridge(tmpDir),
|
||||
});
|
||||
const result = await readTool.execute("read-cap-1", { path: "huge.txt" });
|
||||
const text = extractToolText(result);
|
||||
expect(text).toContain("line-0001");
|
||||
expect(text).toContain("[Read output capped at 50KB for this call. Use offset=");
|
||||
expect(text).not.toContain("line-8000");
|
||||
} finally {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("strips truncation.content details from read results while preserving other fields", async () => {
|
||||
const readResult: AgentToolResult<unknown> = {
|
||||
content: [{ type: "text" as const, text: "line-0001" }],
|
||||
details: {
|
||||
truncation: {
|
||||
truncated: true,
|
||||
outputLines: 1,
|
||||
firstLineExceedsLimit: false,
|
||||
content: "hidden duplicate payload",
|
||||
},
|
||||
},
|
||||
};
|
||||
const baseRead: AgentTool = {
|
||||
name: "read",
|
||||
label: "read",
|
||||
description: "test read",
|
||||
parameters: Type.Object({
|
||||
path: Type.String(),
|
||||
offset: Type.Optional(Type.Number()),
|
||||
limit: Type.Optional(Type.Number()),
|
||||
}),
|
||||
execute: vi.fn(async () => readResult),
|
||||
};
|
||||
|
||||
const wrapped = createOpenClawReadTool(
|
||||
baseRead as unknown as Parameters<typeof createOpenClawReadTool>[0],
|
||||
);
|
||||
const result = await wrapped.execute("read-strip-1", { path: "demo.txt", limit: 1 });
|
||||
|
||||
const details = (result as { details?: { truncation?: Record<string, unknown> } }).details;
|
||||
expect(details?.truncation).toMatchObject({
|
||||
truncated: true,
|
||||
outputLines: 1,
|
||||
firstLineExceedsLimit: false,
|
||||
});
|
||||
expect(details?.truncation).not.toHaveProperty("content");
|
||||
});
|
||||
});
|
||||
@@ -32,7 +32,6 @@ const coreTools = [
|
||||
stubActionTool("session_status", ["get", "show"]),
|
||||
stubTool("tts"),
|
||||
stubTool("image_generate"),
|
||||
stubTool("web_search"),
|
||||
stubTool("web_fetch"),
|
||||
stubTool("image"),
|
||||
stubTool("pdf"),
|
||||
|
||||
Reference in New Issue
Block a user