mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-14 03:20:49 +00:00
Merged via /review-pr -> /prepare-pr -> /merge-pr.
Prepared head SHA: 385dcbd8a9
Co-authored-by: Swader <1430603+Swader@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
202 lines
4.9 KiB
TypeScript
202 lines
4.9 KiB
TypeScript
const MUTATING_TOOL_NAMES = new Set([
|
|
"write",
|
|
"edit",
|
|
"apply_patch",
|
|
"exec",
|
|
"bash",
|
|
"process",
|
|
"message",
|
|
"sessions_send",
|
|
"cron",
|
|
"gateway",
|
|
"canvas",
|
|
"nodes",
|
|
"session_status",
|
|
]);
|
|
|
|
const READ_ONLY_ACTIONS = new Set([
|
|
"get",
|
|
"list",
|
|
"read",
|
|
"status",
|
|
"show",
|
|
"fetch",
|
|
"search",
|
|
"query",
|
|
"view",
|
|
"poll",
|
|
"log",
|
|
"inspect",
|
|
"check",
|
|
"probe",
|
|
]);
|
|
|
|
const PROCESS_MUTATING_ACTIONS = new Set(["write", "send_keys", "submit", "paste", "kill"]);
|
|
|
|
const MESSAGE_MUTATING_ACTIONS = new Set([
|
|
"send",
|
|
"reply",
|
|
"thread_reply",
|
|
"threadreply",
|
|
"edit",
|
|
"delete",
|
|
"react",
|
|
"pin",
|
|
"unpin",
|
|
]);
|
|
|
|
export type ToolMutationState = {
|
|
mutatingAction: boolean;
|
|
actionFingerprint?: string;
|
|
};
|
|
|
|
export type ToolActionRef = {
|
|
toolName: string;
|
|
meta?: string;
|
|
actionFingerprint?: string;
|
|
};
|
|
|
|
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
return value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
|
|
}
|
|
|
|
function normalizeActionName(value: unknown): string | undefined {
|
|
if (typeof value !== "string") {
|
|
return undefined;
|
|
}
|
|
const normalized = value
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[\s-]+/g, "_");
|
|
return normalized || undefined;
|
|
}
|
|
|
|
function normalizeFingerprintValue(value: unknown): string | undefined {
|
|
if (typeof value === "string") {
|
|
const normalized = value.trim();
|
|
return normalized ? normalized.toLowerCase() : undefined;
|
|
}
|
|
if (typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") {
|
|
return String(value).toLowerCase();
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
export function isLikelyMutatingToolName(toolName: string): boolean {
|
|
const normalized = toolName.trim().toLowerCase();
|
|
if (!normalized) {
|
|
return false;
|
|
}
|
|
return (
|
|
MUTATING_TOOL_NAMES.has(normalized) ||
|
|
normalized.endsWith("_actions") ||
|
|
normalized.startsWith("message_") ||
|
|
normalized.includes("send")
|
|
);
|
|
}
|
|
|
|
export function isMutatingToolCall(toolName: string, args: unknown): boolean {
|
|
const normalized = toolName.trim().toLowerCase();
|
|
const record = asRecord(args);
|
|
const action = normalizeActionName(record?.action);
|
|
|
|
switch (normalized) {
|
|
case "write":
|
|
case "edit":
|
|
case "apply_patch":
|
|
case "exec":
|
|
case "bash":
|
|
case "sessions_send":
|
|
return true;
|
|
case "process":
|
|
return action != null && PROCESS_MUTATING_ACTIONS.has(action);
|
|
case "message":
|
|
return (
|
|
(action != null && MESSAGE_MUTATING_ACTIONS.has(action)) ||
|
|
typeof record?.content === "string" ||
|
|
typeof record?.message === "string"
|
|
);
|
|
case "session_status":
|
|
return typeof record?.model === "string" && record.model.trim().length > 0;
|
|
default: {
|
|
if (normalized === "cron" || normalized === "gateway" || normalized === "canvas") {
|
|
return action == null || !READ_ONLY_ACTIONS.has(action);
|
|
}
|
|
if (normalized === "nodes") {
|
|
return action == null || action !== "list";
|
|
}
|
|
if (normalized.endsWith("_actions")) {
|
|
return action == null || !READ_ONLY_ACTIONS.has(action);
|
|
}
|
|
if (normalized.startsWith("message_") || normalized.includes("send")) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
export function buildToolActionFingerprint(
|
|
toolName: string,
|
|
args: unknown,
|
|
meta?: string,
|
|
): string | undefined {
|
|
if (!isMutatingToolCall(toolName, args)) {
|
|
return undefined;
|
|
}
|
|
const normalizedTool = toolName.trim().toLowerCase();
|
|
const record = asRecord(args);
|
|
const action = normalizeActionName(record?.action);
|
|
const parts = [`tool=${normalizedTool}`];
|
|
if (action) {
|
|
parts.push(`action=${action}`);
|
|
}
|
|
for (const key of [
|
|
"path",
|
|
"filePath",
|
|
"oldPath",
|
|
"newPath",
|
|
"to",
|
|
"target",
|
|
"messageId",
|
|
"sessionKey",
|
|
"jobId",
|
|
"id",
|
|
"model",
|
|
]) {
|
|
const value = normalizeFingerprintValue(record?.[key]);
|
|
if (value) {
|
|
parts.push(`${key.toLowerCase()}=${value}`);
|
|
}
|
|
}
|
|
const normalizedMeta = meta?.trim().replace(/\s+/g, " ").toLowerCase();
|
|
if (normalizedMeta) {
|
|
parts.push(`meta=${normalizedMeta}`);
|
|
}
|
|
return parts.join("|");
|
|
}
|
|
|
|
export function buildToolMutationState(
|
|
toolName: string,
|
|
args: unknown,
|
|
meta?: string,
|
|
): ToolMutationState {
|
|
const actionFingerprint = buildToolActionFingerprint(toolName, args, meta);
|
|
return {
|
|
mutatingAction: actionFingerprint != null,
|
|
actionFingerprint,
|
|
};
|
|
}
|
|
|
|
export function isSameToolMutationAction(existing: ToolActionRef, next: ToolActionRef): boolean {
|
|
if (existing.actionFingerprint != null || next.actionFingerprint != null) {
|
|
// For mutating flows, fail closed: only clear when both fingerprints exist and match.
|
|
return (
|
|
existing.actionFingerprint != null &&
|
|
next.actionFingerprint != null &&
|
|
existing.actionFingerprint === next.actionFingerprint
|
|
);
|
|
}
|
|
return existing.toolName === next.toolName && (existing.meta ?? "") === (next.meta ?? "");
|
|
}
|