Agents: add Claude Code parameter aliasing for read/write/edit tools
This commit is contained in:
@@ -586,6 +586,89 @@ describe("createClawdbotCodingTools", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts Claude Code parameter aliases for read/write/edit", async () => {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-alias-"));
|
||||||
|
try {
|
||||||
|
const tools = createClawdbotCodingTools({ workspaceDir: tmpDir });
|
||||||
|
const readTool = tools.find((tool) => tool.name === "read");
|
||||||
|
const writeTool = tools.find((tool) => tool.name === "write");
|
||||||
|
const editTool = tools.find((tool) => tool.name === "edit");
|
||||||
|
expect(readTool).toBeDefined();
|
||||||
|
expect(writeTool).toBeDefined();
|
||||||
|
expect(editTool).toBeDefined();
|
||||||
|
|
||||||
|
const filePath = "alias-test.txt";
|
||||||
|
await writeTool?.execute("tool-alias-1", {
|
||||||
|
file_path: filePath,
|
||||||
|
content: "hello world",
|
||||||
|
});
|
||||||
|
|
||||||
|
await editTool?.execute("tool-alias-2", {
|
||||||
|
file_path: filePath,
|
||||||
|
old_string: "world",
|
||||||
|
new_string: "universe",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await readTool?.execute("tool-alias-3", {
|
||||||
|
file_path: filePath,
|
||||||
|
});
|
||||||
|
|
||||||
|
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 universe");
|
||||||
|
} finally {
|
||||||
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies sandbox path guards to file_path alias", async () => {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sbx-"));
|
||||||
|
const outsidePath = path.join(os.tmpdir(), "clawdbot-outside.txt");
|
||||||
|
await fs.writeFile(outsidePath, "outside", "utf8");
|
||||||
|
try {
|
||||||
|
const sandbox = {
|
||||||
|
enabled: true,
|
||||||
|
sessionKey: "sandbox:test",
|
||||||
|
workspaceDir: tmpDir,
|
||||||
|
agentWorkspaceDir: path.join(os.tmpdir(), "clawdbot-workspace"),
|
||||||
|
workspaceAccess: "ro",
|
||||||
|
containerName: "clawdbot-sbx-test",
|
||||||
|
containerWorkdir: "/workspace",
|
||||||
|
docker: {
|
||||||
|
image: "clawdbot-sandbox:bookworm-slim",
|
||||||
|
containerPrefix: "clawdbot-sbx-",
|
||||||
|
workdir: "/workspace",
|
||||||
|
readOnlyRoot: true,
|
||||||
|
tmpfs: [],
|
||||||
|
network: "none",
|
||||||
|
user: "1000:1000",
|
||||||
|
capDrop: ["ALL"],
|
||||||
|
env: { LANG: "C.UTF-8" },
|
||||||
|
},
|
||||||
|
tools: {
|
||||||
|
allow: ["read"],
|
||||||
|
deny: [],
|
||||||
|
},
|
||||||
|
browserAllowHostControl: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const tools = createClawdbotCodingTools({ sandbox });
|
||||||
|
const readTool = tools.find((tool) => tool.name === "read");
|
||||||
|
expect(readTool).toBeDefined();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
readTool?.execute("tool-sbx-1", { file_path: outsidePath }),
|
||||||
|
).rejects.toThrow();
|
||||||
|
} finally {
|
||||||
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
await fs.rm(outsidePath, { force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("falls back to process.cwd() when workspaceDir not provided", () => {
|
it("falls back to process.cwd() when workspaceDir not provided", () => {
|
||||||
const prevCwd = process.cwd();
|
const prevCwd = process.cwd();
|
||||||
const tools = createClawdbotCodingTools();
|
const tools = createClawdbotCodingTools();
|
||||||
|
|||||||
@@ -414,15 +414,17 @@ function wrapSandboxPathGuard(tool: AnyAgentTool, root: string): AnyAgentTool {
|
|||||||
return {
|
return {
|
||||||
...tool,
|
...tool,
|
||||||
execute: async (toolCallId, args, signal, onUpdate) => {
|
execute: async (toolCallId, args, signal, onUpdate) => {
|
||||||
|
const normalized = normalizeToolParams(args);
|
||||||
const record =
|
const record =
|
||||||
args && typeof args === "object"
|
normalized ??
|
||||||
|
(args && typeof args === "object"
|
||||||
? (args as Record<string, unknown>)
|
? (args as Record<string, unknown>)
|
||||||
: undefined;
|
: undefined);
|
||||||
const filePath = record?.path;
|
const filePath = record?.path;
|
||||||
if (typeof filePath === "string" && filePath.trim()) {
|
if (typeof filePath === "string" && filePath.trim()) {
|
||||||
await assertSandboxPath({ filePath, cwd: root, root });
|
await assertSandboxPath({ filePath, cwd: root, root });
|
||||||
}
|
}
|
||||||
return tool.execute(toolCallId, args, signal, onUpdate);
|
return tool.execute(toolCallId, normalized ?? args, signal, onUpdate);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -434,31 +436,134 @@ function createSandboxedReadTool(root: string) {
|
|||||||
|
|
||||||
function createSandboxedWriteTool(root: string) {
|
function createSandboxedWriteTool(root: string) {
|
||||||
const base = createWriteTool(root);
|
const base = createWriteTool(root);
|
||||||
return wrapSandboxPathGuard(base as unknown as AnyAgentTool, root);
|
return wrapSandboxPathGuard(wrapToolParamNormalization(base), root);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSandboxedEditTool(root: string) {
|
function createSandboxedEditTool(root: string) {
|
||||||
const base = createEditTool(root);
|
const base = createEditTool(root);
|
||||||
return wrapSandboxPathGuard(base as unknown as AnyAgentTool, root);
|
return wrapSandboxPathGuard(wrapToolParamNormalization(base), root);
|
||||||
|
}
|
||||||
|
function createWhatsAppLoginTool(): AnyAgentTool {
|
||||||
|
return {
|
||||||
|
label: "WhatsApp Login",
|
||||||
|
name: "whatsapp_login",
|
||||||
|
description:
|
||||||
|
"Generate a WhatsApp QR code for linking, or wait for the scan to complete.",
|
||||||
|
// NOTE: Using Type.Unsafe for action enum instead of Type.Union([Type.Literal(...)])
|
||||||
|
// because Claude API on Vertex AI rejects nested anyOf schemas as invalid JSON Schema.
|
||||||
|
parameters: Type.Object({
|
||||||
|
action: Type.Unsafe<"start" | "wait">({
|
||||||
|
type: "string",
|
||||||
|
enum: ["start", "wait"],
|
||||||
|
}),
|
||||||
|
timeoutMs: Type.Optional(Type.Number()),
|
||||||
|
force: Type.Optional(Type.Boolean()),
|
||||||
|
}),
|
||||||
|
execute: async (_toolCallId, args) => {
|
||||||
|
const action = (args as { action?: string })?.action ?? "start";
|
||||||
|
if (action === "wait") {
|
||||||
|
const result = await waitForWebLogin({
|
||||||
|
timeoutMs:
|
||||||
|
typeof (args as { timeoutMs?: unknown }).timeoutMs === "number"
|
||||||
|
? (args as { timeoutMs?: number }).timeoutMs
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: result.message }],
|
||||||
|
details: { connected: result.connected },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await startWebLoginWithQr({
|
||||||
|
timeoutMs:
|
||||||
|
typeof (args as { timeoutMs?: unknown }).timeoutMs === "number"
|
||||||
|
? (args as { timeoutMs?: number }).timeoutMs
|
||||||
|
: undefined,
|
||||||
|
force:
|
||||||
|
typeof (args as { force?: unknown }).force === "boolean"
|
||||||
|
? (args as { force?: boolean }).force
|
||||||
|
: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.qrDataUrl) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: result.message,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
details: { qr: false },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = [
|
||||||
|
result.message,
|
||||||
|
"",
|
||||||
|
"Open WhatsApp → Linked Devices and scan:",
|
||||||
|
"",
|
||||||
|
``,
|
||||||
|
].join("\n");
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text }],
|
||||||
|
details: { qr: true },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
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 };
|
||||||
|
// file_path → path (read, write, edit)
|
||||||
|
if ("file_path" in normalized && !("path" in normalized)) {
|
||||||
|
normalized.path = normalized.file_path;
|
||||||
|
delete normalized.file_path;
|
||||||
|
}
|
||||||
|
// old_string → oldText (edit)
|
||||||
|
if ("old_string" in normalized && !("oldText" in normalized)) {
|
||||||
|
normalized.oldText = normalized.old_string;
|
||||||
|
delete normalized.old_string;
|
||||||
|
}
|
||||||
|
// new_string → newText (edit)
|
||||||
|
if ("new_string" in normalized && !("newText" in normalized)) {
|
||||||
|
normalized.newText = normalized.new_string;
|
||||||
|
delete normalized.new_string;
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic wrapper to normalize parameters for any tool
|
||||||
|
function wrapToolParamNormalization(tool: AnyAgentTool): AnyAgentTool {
|
||||||
|
return {
|
||||||
|
...tool,
|
||||||
|
execute: async (toolCallId, params, signal, onUpdate) => {
|
||||||
|
const normalized = normalizeToolParams(params);
|
||||||
|
return tool.execute(toolCallId, normalized ?? params, signal, onUpdate);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
function createClawdbotReadTool(base: AnyAgentTool): AnyAgentTool {
|
function createClawdbotReadTool(base: AnyAgentTool): AnyAgentTool {
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
execute: async (toolCallId, params, signal) => {
|
execute: async (toolCallId, params, signal) => {
|
||||||
|
const normalized = normalizeToolParams(params);
|
||||||
const result = (await base.execute(
|
const result = (await base.execute(
|
||||||
toolCallId,
|
toolCallId,
|
||||||
params,
|
normalized ?? params,
|
||||||
signal,
|
signal,
|
||||||
)) as AgentToolResult<unknown>;
|
)) as AgentToolResult<unknown>;
|
||||||
const record =
|
const record = normalized ?? (params as Record<string, unknown>);
|
||||||
params && typeof params === "object"
|
|
||||||
? (params as Record<string, unknown>)
|
|
||||||
: undefined;
|
|
||||||
const filePath =
|
const filePath =
|
||||||
typeof record?.path === "string" ? String(record.path) : "<unknown>";
|
typeof record?.path === "string" ? String(record.path) : "<unknown>";
|
||||||
const normalized = await normalizeReadImageResult(result, filePath);
|
const normalizedResult = await normalizeReadImageResult(result, filePath);
|
||||||
return sanitizeToolResultImages(normalized, `read:${filePath}`);
|
return sanitizeToolResultImages(normalizedResult, `read:${filePath}`);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -581,11 +686,13 @@ export function createClawdbotCodingTools(options?: {
|
|||||||
if (tool.name === "bash" || tool.name === execToolName) return [];
|
if (tool.name === "bash" || tool.name === execToolName) return [];
|
||||||
if (tool.name === "write") {
|
if (tool.name === "write") {
|
||||||
if (sandboxRoot) return [];
|
if (sandboxRoot) return [];
|
||||||
return [createWriteTool(workspaceRoot)];
|
// Wrap with param normalization for Claude Code compatibility
|
||||||
|
return [wrapToolParamNormalization(createWriteTool(workspaceRoot))];
|
||||||
}
|
}
|
||||||
if (tool.name === "edit") {
|
if (tool.name === "edit") {
|
||||||
if (sandboxRoot) return [];
|
if (sandboxRoot) return [];
|
||||||
return [createEditTool(workspaceRoot)];
|
// Wrap with param normalization for Claude Code compatibility
|
||||||
|
return [wrapToolParamNormalization(createEditTool(workspaceRoot))];
|
||||||
}
|
}
|
||||||
return [tool as AnyAgentTool];
|
return [tool as AnyAgentTool];
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user