mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-29 19:01:44 +00:00
Merged via squash.
Prepared head SHA: e23bde893a
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
229 lines
6.2 KiB
TypeScript
229 lines
6.2 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;
|
|
}
|
|
|
|
function appendFingerprintAlias(
|
|
parts: string[],
|
|
record: Record<string, unknown> | undefined,
|
|
label: string,
|
|
keys: string[],
|
|
): boolean {
|
|
for (const key of keys) {
|
|
const value = normalizeFingerprintValue(record?.[key]);
|
|
if (!value) {
|
|
continue;
|
|
}
|
|
parts.push(`${label}=${value}`);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
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}`);
|
|
}
|
|
let hasStableTarget = false;
|
|
hasStableTarget =
|
|
appendFingerprintAlias(parts, record, "path", [
|
|
"path",
|
|
"file_path",
|
|
"filePath",
|
|
"filepath",
|
|
"file",
|
|
]) || hasStableTarget;
|
|
hasStableTarget =
|
|
appendFingerprintAlias(parts, record, "oldpath", ["oldPath", "old_path"]) || hasStableTarget;
|
|
hasStableTarget =
|
|
appendFingerprintAlias(parts, record, "newpath", ["newPath", "new_path"]) || hasStableTarget;
|
|
hasStableTarget =
|
|
appendFingerprintAlias(parts, record, "to", ["to", "target"]) || hasStableTarget;
|
|
hasStableTarget =
|
|
appendFingerprintAlias(parts, record, "messageid", ["messageId", "message_id"]) ||
|
|
hasStableTarget;
|
|
hasStableTarget =
|
|
appendFingerprintAlias(parts, record, "sessionkey", ["sessionKey", "session_key"]) ||
|
|
hasStableTarget;
|
|
hasStableTarget =
|
|
appendFingerprintAlias(parts, record, "jobid", ["jobId", "job_id"]) || hasStableTarget;
|
|
hasStableTarget = appendFingerprintAlias(parts, record, "id", ["id"]) || hasStableTarget;
|
|
hasStableTarget = appendFingerprintAlias(parts, record, "model", ["model"]) || hasStableTarget;
|
|
const normalizedMeta = meta?.trim().replace(/\s+/g, " ").toLowerCase();
|
|
// Meta text often carries volatile details (for example "N chars").
|
|
// Prefer stable arg-derived keys for matching; only fall back to meta
|
|
// when no stable target key is available.
|
|
if (normalizedMeta && !hasStableTarget) {
|
|
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 ?? "");
|
|
}
|