Files
openclaw/extensions/diffs/src/tool.ts
Masato Hoshino 1225aa822f fix(diffs): stop naming gated tools in model-visible guidance (#115919)
The Diffs plugin named the `message` tool from three model-visible surfaces:
the guidance it injects via before_prompt_build, the mode=file tool result,
and its SKILL.md. `disableMessageTool` removes that tool in eight production
paths (subagent spawn, system agent, worker runtime, both skills-workshop
reviews, companion-ask, restart sentinel, active-memory recall), so agents in
those sessions were told to call a tool absent from their tool list.

The same surfaces plus the `diffs` tool description named the separately
gated Canvas plugin — SKILL.md through `canvas present` / `canvas navigate`,
the others through "for canvas use".

All of them now describe the capability instead of naming the tool, per the
root AGENTS.md rule that descriptions never statically name tools from other
toolsets. The two pinned assertions keep their snapshots and now also assert
the invariant directly.

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-07-30 04:22:10 +08:00

531 lines
16 KiB
TypeScript

// Diffs plugin module implements tool behavior.
import fs from "node:fs/promises";
import { optionalFiniteNumberSchema, stringEnum } from "openclaw/plugin-sdk/channel-actions";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { readFiniteNumberParam } from "openclaw/plugin-sdk/param-readers";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { Type } from "typebox";
import type { Static } from "typebox";
import type { AnyAgentTool, OpenClawPluginApi, OpenClawPluginToolContext } from "../api.js";
import { PlaywrightDiffScreenshotter, type DiffScreenshotter } from "./browser.js";
import { resolveDiffImageRenderOptions } from "./config.js";
import { DiffRenderInputError, renderDiffDocument } from "./render.js";
import type { DiffArtifactStore } from "./store.js";
import type {
DiffArtifactContext,
DiffRenderOptions,
DiffRenderTarget,
DiffToolDefaults,
} from "./types.js";
import {
DIFF_IMAGE_QUALITY_PRESETS,
DIFF_LAYOUTS,
DIFF_MODES,
DIFF_OUTPUT_FORMATS,
DIFF_THEMES,
type DiffInput,
type DiffImageQualityPreset,
type DiffLayout,
type DiffMode,
type DiffOutputFormat,
type DiffTheme,
} from "./types.js";
import { buildViewerUrl, normalizeViewerBaseUrl } from "./url.js";
const MAX_BEFORE_AFTER_BYTES = 512 * 1024;
const MAX_PATCH_BYTES = 2 * 1024 * 1024;
const MAX_TITLE_BYTES = 1_024;
const MAX_PATH_BYTES = 2_048;
const MAX_LANG_BYTES = 128;
const MAX_DIFF_ARTIFACT_TTL_SECONDS = 21_600;
const DiffsToolSchema = Type.Object(
{
before: Type.Optional(Type.String({ description: "Original text content." })),
after: Type.Optional(Type.String({ description: "Updated text content." })),
patch: Type.Optional(
Type.String({
description: "Unified diff or patch text.",
maxLength: MAX_PATCH_BYTES,
}),
),
path: Type.Optional(
Type.String({
description: "Display path for before/after input.",
maxLength: MAX_PATH_BYTES,
}),
),
lang: Type.Optional(
Type.String({
description: "Optional language override for before/after input.",
maxLength: MAX_LANG_BYTES,
}),
),
title: Type.Optional(
Type.String({
description: "Optional title for the rendered diff.",
maxLength: MAX_TITLE_BYTES,
}),
),
mode: Type.Optional(
stringEnum(DIFF_MODES, {
description:
"Output mode: view, file, image (deprecated alias for file), or both. Default: both.",
}),
),
theme: Type.Optional(stringEnum(DIFF_THEMES, { description: "Viewer theme. Default: dark." })),
layout: Type.Optional(
stringEnum(DIFF_LAYOUTS, { description: "Diff layout. Default: unified." }),
),
fileQuality: Type.Optional(
stringEnum(DIFF_IMAGE_QUALITY_PRESETS, {
description: "File quality preset: standard, hq, or print.",
}),
),
fileFormat: Type.Optional(
stringEnum(DIFF_OUTPUT_FORMATS, { description: "Rendered file format: png or pdf." }),
),
fileScale: optionalFiniteNumberSchema({
description: "Optional rendered-file device scale factor override (1-4).",
minimum: 1,
maximum: 4,
}),
fileMaxWidth: optionalFiniteNumberSchema({
description: "Optional rendered-file max width in CSS pixels (640-2400).",
minimum: 640,
maximum: 2400,
}),
expandUnchanged: Type.Optional(
Type.Boolean({ description: "Expand unchanged sections instead of collapsing them." }),
),
ttlSeconds: optionalFiniteNumberSchema({
description: "Artifact lifetime in seconds. Default: 1800. Maximum: 21600.",
minimum: 1,
maximum: MAX_DIFF_ARTIFACT_TTL_SECONDS,
}),
baseUrl: Type.Optional(
Type.String({
description:
"Optional gateway base URL override used when building the viewer URL. Overrides configured viewerBaseUrl, for example https://gateway.example.com.",
}),
),
},
{ additionalProperties: false },
);
type DiffsToolParams = Static<typeof DiffsToolSchema>;
export function createDiffsTool(params: {
api: OpenClawPluginApi;
store: DiffArtifactStore;
defaults: DiffToolDefaults;
viewerBaseUrl?: string;
languagePackAvailable?: boolean;
screenshotter?: DiffScreenshotter;
context?: OpenClawPluginToolContext;
}): AnyAgentTool {
return {
name: "diffs",
label: "Diffs",
description:
"Create a read-only diff viewer from before/after text or a unified patch. Returns a gateway viewer URL for interactive viewing and can also render the same diff to a PNG or PDF.",
parameters: DiffsToolSchema,
execute: async (_toolCallId, rawParams) => {
const toolParams = rawParams as DiffsToolParams;
const rawRecord = rawParams as Record<string, unknown>;
const artifactContext = buildArtifactContext(params.context);
const input = normalizeDiffInput(toolParams);
if (input.kind === "before_after" && input.before === input.after) {
return {
content: [
{
type: "text",
text: "Before and after are identical — no changes to render.",
},
],
details: {
changed: false,
...(artifactContext ? { context: artifactContext } : {}),
},
};
}
const mode = normalizeMode(toolParams.mode, params.defaults.mode);
const theme = normalizeTheme(toolParams.theme, params.defaults.theme);
const layout = normalizeLayout(toolParams.layout, params.defaults.layout);
const expandUnchanged = toolParams.expandUnchanged === true;
const ttlSeconds =
readFiniteNumberParam(rawRecord, "ttlSeconds") ?? params.defaults.ttlSeconds;
const fileScale = readFiniteNumberParam(rawRecord, "fileScale");
const fileMaxWidth = readFiniteNumberParam(rawRecord, "fileMaxWidth");
const ttlMs = normalizeTtlMs(ttlSeconds);
const image = resolveDiffImageRenderOptions({
defaults: params.defaults,
fileFormat: normalizeOutputFormat(toolParams.fileFormat),
fileQuality: normalizeFileQuality(toolParams.fileQuality),
fileScale,
fileMaxWidth,
});
const renderTarget = resolveRenderTarget(mode);
const rendered = await renderDiffDocument(
input,
{
presentation: {
...params.defaults,
layout,
theme,
},
image,
expandUnchanged,
languagePackAvailable: params.languagePackAvailable,
},
renderTarget,
).catch((error: unknown) => {
if (error instanceof DiffRenderInputError) {
throw new PluginToolInputError(error.message);
}
throw error;
});
const screenshotter =
params.screenshotter ?? new PlaywrightDiffScreenshotter({ config: params.api.config });
if (isArtifactOnlyMode(mode)) {
const artifactFile = await renderDiffArtifactFile({
screenshotter,
store: params.store,
html: requireRenderedHtml(rendered.imageHtml, "image"),
theme,
image,
ttlMs,
context: artifactContext,
});
return {
content: [
{
type: "text",
text: buildFileArtifactMessage({
format: image.format,
filePath: artifactFile.path,
}),
},
],
details: buildArtifactDetails({
baseDetails: {
changed: true,
...(artifactFile.artifactId ? { artifactId: artifactFile.artifactId } : {}),
...(artifactFile.expiresAt ? { expiresAt: artifactFile.expiresAt } : {}),
title: rendered.title,
inputKind: rendered.inputKind,
fileCount: rendered.fileCount,
mode,
...(artifactContext ? { context: artifactContext } : {}),
},
artifactFile,
image,
}),
};
}
const artifact = await params.store.createArtifact({
html: requireRenderedHtml(rendered.html, "viewer"),
title: rendered.title,
inputKind: rendered.inputKind,
fileCount: rendered.fileCount,
ttlMs,
context: artifactContext,
});
const viewerUrl = buildViewerUrl({
config: params.api.config,
viewerPath: artifact.viewerPath,
baseUrl: normalizeBaseUrl(toolParams.baseUrl) ?? params.viewerBaseUrl,
});
const baseDetails = {
changed: true,
artifactId: artifact.id,
viewerUrl,
viewerPath: artifact.viewerPath,
title: artifact.title,
expiresAt: artifact.expiresAt,
inputKind: artifact.inputKind,
fileCount: artifact.fileCount,
mode,
...(artifactContext ? { context: artifactContext } : {}),
};
if (mode === "view") {
return {
content: [
{
type: "text",
text: `Diff viewer ready.\n${viewerUrl}`,
},
],
details: baseDetails,
};
}
try {
const artifactFile = await renderDiffArtifactFile({
screenshotter,
store: params.store,
html: requireRenderedHtml(rendered.imageHtml, "image"),
theme,
image,
ttlMs,
context: artifactContext,
});
return {
content: [
{
type: "text",
text: buildFileArtifactMessage({
format: image.format,
filePath: artifactFile.path,
viewerUrl,
}),
},
],
details: buildArtifactDetails({
baseDetails,
artifactFile,
image,
}),
};
} catch (error) {
if (mode === "both") {
const errorMessage = formatErrorMessage(error);
return {
content: [
{
type: "text",
text: `Diff viewer ready.\n${viewerUrl}\nFile rendering failed: ${errorMessage}`,
},
],
details: {
...baseDetails,
fileError: errorMessage,
},
};
}
throw error;
}
},
};
}
function normalizeFileQuality(
fileQuality: DiffImageQualityPreset | undefined,
): DiffImageQualityPreset | undefined {
return fileQuality && DIFF_IMAGE_QUALITY_PRESETS.includes(fileQuality) ? fileQuality : undefined;
}
function normalizeOutputFormat(format: DiffOutputFormat | undefined): DiffOutputFormat | undefined {
return format && DIFF_OUTPUT_FORMATS.includes(format) ? format : undefined;
}
function isArtifactOnlyMode(mode: DiffMode): mode is "image" | "file" {
return mode === "image" || mode === "file";
}
function resolveRenderTarget(mode: DiffMode): DiffRenderTarget {
if (mode === "view") {
return "viewer";
}
if (isArtifactOnlyMode(mode)) {
return "image";
}
return "both";
}
function requireRenderedHtml(html: string | undefined, target: DiffRenderTarget): string {
if (html !== undefined) {
return html;
}
throw new Error(`Missing ${target} render output.`);
}
function buildArtifactDetails(params: {
baseDetails: Record<string, unknown>;
artifactFile: { path: string; bytes: number };
image: DiffRenderOptions["image"];
}) {
return {
...params.baseDetails,
filePath: params.artifactFile.path,
// `path` mirrors filePath so the message tool can send the artifact directly.
path: params.artifactFile.path,
fileBytes: params.artifactFile.bytes,
fileFormat: params.image.format,
fileQuality: params.image.qualityPreset,
fileScale: params.image.scale,
fileMaxWidth: params.image.maxWidth,
};
}
function buildFileArtifactMessage(params: {
format: DiffOutputFormat;
filePath: string;
viewerUrl?: string;
}): string {
const lines = params.viewerUrl ? [`Diff viewer: ${params.viewerUrl}`] : [];
lines.push(`Diff ${params.format.toUpperCase()} generated at: ${params.filePath}`);
lines.push("To send this file, use an available file-sending tool to send it as an attachment.");
return lines.join("\n");
}
async function renderDiffArtifactFile(params: {
screenshotter: DiffScreenshotter;
store: DiffArtifactStore;
html: string;
theme: DiffTheme;
image: DiffRenderOptions["image"];
ttlMs?: number;
context?: DiffArtifactContext;
}): Promise<{ path: string; bytes: number; artifactId?: string; expiresAt?: string }> {
const fileArtifact = await params.store.createStandaloneFileArtifact({
format: params.image.format,
ttlMs: params.ttlMs,
context: params.context,
});
try {
await params.screenshotter.screenshotHtml({
html: params.html,
outputPath: fileArtifact.filePath,
theme: params.theme,
image: params.image,
});
const stats = await fs.stat(fileArtifact.filePath);
await params.store.completeFileArtifact(fileArtifact.id);
return {
path: fileArtifact.filePath,
bytes: stats.size,
artifactId: fileArtifact.id,
expiresAt: fileArtifact.expiresAt,
};
} catch (error) {
await params.store.deleteFileArtifact(fileArtifact.id);
throw error;
}
}
function buildArtifactContext(
context: OpenClawPluginToolContext | undefined,
): DiffArtifactContext | undefined {
if (!context) {
return undefined;
}
const agentId = normalizeOptionalString(context.agentId);
const sessionId = normalizeOptionalString(context.sessionId);
const messageChannel = normalizeOptionalString(context.messageChannel);
const agentAccountId = normalizeOptionalString(context.agentAccountId);
const artifactContext: DiffArtifactContext = {
...(agentId ? { agentId } : {}),
...(sessionId ? { sessionId } : {}),
...(messageChannel ? { messageChannel } : {}),
...(agentAccountId ? { agentAccountId } : {}),
};
return Object.keys(artifactContext).length > 0 ? artifactContext : undefined;
}
function normalizeDiffInput(params: DiffsToolParams): DiffInput {
const patch = params.patch?.trim();
const before = params.before;
const after = params.after;
if (patch) {
assertMaxBytes(patch, "patch", MAX_PATCH_BYTES);
if (before !== undefined || after !== undefined) {
throw new PluginToolInputError("Provide either patch or before/after input, not both.");
}
const title = params.title?.trim();
if (title) {
assertMaxBytes(title, "title", MAX_TITLE_BYTES);
}
return {
kind: "patch",
patch,
title,
};
}
if (before === undefined || after === undefined) {
throw new PluginToolInputError("Provide patch or both before and after text.");
}
assertMaxBytes(before, "before", MAX_BEFORE_AFTER_BYTES);
assertMaxBytes(after, "after", MAX_BEFORE_AFTER_BYTES);
const path = normalizeOptionalString(params.path);
const lang = normalizeOptionalString(params.lang);
const title = normalizeOptionalString(params.title);
if (path) {
assertMaxBytes(path, "path", MAX_PATH_BYTES);
}
if (lang) {
assertMaxBytes(lang, "lang", MAX_LANG_BYTES);
}
if (title) {
assertMaxBytes(title, "title", MAX_TITLE_BYTES);
}
return {
kind: "before_after",
before,
after,
path,
lang,
title,
};
}
function assertMaxBytes(value: string, label: string, maxBytes: number): void {
if (Buffer.byteLength(value, "utf8") <= maxBytes) {
return;
}
throw new PluginToolInputError(`${label} exceeds maximum size (${maxBytes} bytes).`);
}
function normalizeBaseUrl(baseUrl?: string): string | undefined {
const normalized = baseUrl?.trim();
if (!normalized) {
return undefined;
}
try {
return normalizeViewerBaseUrl(normalized);
} catch {
throw new PluginToolInputError(`Invalid baseUrl: ${normalized}`);
}
}
function normalizeMode(mode: DiffMode | undefined, fallback: DiffMode): DiffMode {
return mode && DIFF_MODES.includes(mode) ? mode : fallback;
}
function normalizeTheme(theme: DiffTheme | undefined, fallback: DiffTheme): DiffTheme {
return theme && DIFF_THEMES.includes(theme) ? theme : fallback;
}
function normalizeLayout(layout: DiffLayout | undefined, fallback: DiffLayout): DiffLayout {
return layout && DIFF_LAYOUTS.includes(layout) ? layout : fallback;
}
function normalizeTtlMs(ttlSeconds?: number): number | undefined {
if (!Number.isFinite(ttlSeconds) || ttlSeconds === undefined) {
return undefined;
}
return Math.floor(Math.min(Math.max(ttlSeconds, 1), MAX_DIFF_ARTIFACT_TTL_SECONDS) * 1000);
}
class PluginToolInputError extends Error {
constructor(message: string) {
super(message);
this.name = "ToolInputError";
}
}