From d56fe040b4551380c8249848694fcd0af4d3efde Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 7 Apr 2026 17:07:35 +0100 Subject: [PATCH] refactor: dedupe agent lowercase helpers --- src/agents/agent-scope.ts | 2 +- src/agents/bash-tools.exec-approval-followup.ts | 3 ++- src/agents/cli-output.ts | 2 +- src/agents/exec-approval-result.ts | 4 +++- src/agents/github-copilot-token.ts | 3 ++- src/agents/google-transport-stream.ts | 7 ++++--- src/agents/live-auth-keys.ts | 9 ++++++--- src/agents/media-generation-task-status-shared.ts | 3 ++- src/agents/model-auth.ts | 7 +++++-- src/agents/model-scan.ts | 3 ++- src/agents/model-selection.ts | 10 ++++++---- src/agents/payload-redaction.ts | 2 +- .../pi-embedded-runner/google-stream-wrappers.ts | 2 +- src/agents/pi-embedded-subscribe.handlers.tools.ts | 4 +++- src/agents/pi-embedded-subscribe.tools.ts | 2 +- src/agents/prompt-cache-stability.ts | 4 +++- src/agents/provider-request-config.ts | 11 ++++++----- src/agents/skills-install-download.ts | 5 ++++- src/agents/skills.ts | 3 ++- src/agents/subagent-spawn.ts | 9 ++++++--- src/agents/tool-mutation.ts | 6 +++--- src/agents/tools/canvas-tool.ts | 4 ++-- src/agents/tools/cron-tool.ts | 2 +- src/agents/tools/gateway.ts | 3 ++- src/agents/tools/nodes-tool-media.ts | 9 +++++---- src/agents/tools/nodes-tool.ts | 3 ++- src/agents/tools/nodes-utils.ts | 2 +- src/agents/tools/web-fetch-utils.ts | 3 ++- src/agents/tools/web-fetch-visibility.ts | 9 ++++++--- src/agents/tools/web-fetch.ts | 3 ++- 30 files changed, 87 insertions(+), 52 deletions(-) diff --git a/src/agents/agent-scope.ts b/src/agents/agent-scope.ts index affda75716b..71ef063d245 100644 --- a/src/agents/agent-scope.ts +++ b/src/agents/agent-scope.ts @@ -293,7 +293,7 @@ function normalizePathForComparison(input: string): string { // Keep lexical path for non-existent directories. } if (process.platform === "win32") { - return normalized.toLowerCase(); + return normalizeLowercaseStringOrEmpty(normalized); } return normalized; } diff --git a/src/agents/bash-tools.exec-approval-followup.ts b/src/agents/bash-tools.exec-approval-followup.ts index 1c7e0938b3c..6d7b683077a 100644 --- a/src/agents/bash-tools.exec-approval-followup.ts +++ b/src/agents/bash-tools.exec-approval-followup.ts @@ -4,6 +4,7 @@ import { } from "../infra/outbound/best-effort-delivery.js"; import { sendMessage } from "../infra/outbound/message.js"; import { isCronSessionKey, isSubagentSessionKey } from "../sessions/session-key-utils.js"; +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { isGatewayMessageChannel, normalizeMessageChannel } from "../utils/message-channel.js"; import { formatExecDeniedUserMessage, @@ -90,7 +91,7 @@ function formatDirectExecApprovalFollowupText( } if (parsed.kind === "finished") { - const metadata = parsed.metadata.toLowerCase(); + const metadata = normalizeLowercaseStringOrEmpty(parsed.metadata); const body = sanitizeUserFacingText(parsed.body, { errorContext: !metadata.includes("code 0"), }).trim(); diff --git a/src/agents/cli-output.ts b/src/agents/cli-output.ts index bbfaca5fb62..61d56586a48 100644 --- a/src/agents/cli-output.ts +++ b/src/agents/cli-output.ts @@ -398,7 +398,7 @@ export function parseCliJsonl( const item = isRecord(parsed.item) ? parsed.item : null; if (item && typeof item.text === "string") { - const type = typeof item.type === "string" ? item.type.toLowerCase() : ""; + const type = normalizeLowercaseStringOrEmpty(item.type); if (!type || type.includes("message")) { texts.push(item.text); } diff --git a/src/agents/exec-approval-result.ts b/src/agents/exec-approval-result.ts index bb2334d391e..140c586ac8e 100644 --- a/src/agents/exec-approval-result.ts +++ b/src/agents/exec-approval-result.ts @@ -1,3 +1,5 @@ +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; + export type ExecApprovalResult = | { kind: "denied"; @@ -73,7 +75,7 @@ export function formatExecDeniedUserMessage(resultText: string): string | null { return null; } - const metadata = parsed.metadata.toLowerCase(); + const metadata = normalizeLowercaseStringOrEmpty(parsed.metadata); if (metadata.includes("approval-timeout")) { return "Command did not run: approval timed out."; } diff --git a/src/agents/github-copilot-token.ts b/src/agents/github-copilot-token.ts index f1874cd9df3..cf770d3159b 100644 --- a/src/agents/github-copilot-token.ts +++ b/src/agents/github-copilot-token.ts @@ -1,6 +1,7 @@ import path from "node:path"; import { resolveStateDir } from "../config/paths.js"; import { loadJsonFile, saveJsonFile } from "../infra/json-file.js"; +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { buildCopilotIdeHeaders } from "./copilot-dynamic-headers.js"; import { resolveProviderEndpoint } from "./provider-attribution.js"; @@ -69,7 +70,7 @@ function resolveCopilotProxyHost(proxyEp: string): string | null { if (url.protocol !== "http:" && url.protocol !== "https:") { return null; } - return url.hostname.toLowerCase(); + return normalizeLowercaseStringOrEmpty(url.hostname); } catch { return null; } diff --git a/src/agents/google-transport-stream.ts b/src/agents/google-transport-stream.ts index 5c66b722ba8..56d5e257137 100644 --- a/src/agents/google-transport-stream.ts +++ b/src/agents/google-transport-stream.ts @@ -9,6 +9,7 @@ import { } from "@mariozechner/pi-ai"; import { parseGeminiAuth } from "../infra/gemini-auth.js"; import { normalizeGoogleApiBaseUrl } from "../infra/google-api-base-url.js"; +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { buildGuardedModelFetch } from "./provider-transport-fetch.js"; import { stripSystemPromptCacheBoundary } from "./system-prompt-cache-boundary.js"; import { transformTransportMessages } from "./transport-message-transform.js"; @@ -112,11 +113,11 @@ type GoogleSseChunk = { let toolCallCounter = 0; function isGemini3ProModel(modelId: string): boolean { - return /gemini-3(?:\.\d+)?-pro/.test(modelId.toLowerCase()); + return /gemini-3(?:\.\d+)?-pro/.test(normalizeLowercaseStringOrEmpty(modelId)); } function isGemini3FlashModel(modelId: string): boolean { - return /gemini-3(?:\.\d+)?-flash/.test(modelId.toLowerCase()); + return /gemini-3(?:\.\d+)?-flash/.test(normalizeLowercaseStringOrEmpty(modelId)); } function requiresToolCallId(modelId: string): boolean { @@ -124,7 +125,7 @@ function requiresToolCallId(modelId: string): boolean { } function supportsMultimodalFunctionResponse(modelId: string): boolean { - const match = modelId.toLowerCase().match(/^gemini(?:-live)?-(\d+)/); + const match = normalizeLowercaseStringOrEmpty(modelId).match(/^gemini(?:-live)?-(\d+)/); if (!match) { return true; } diff --git a/src/agents/live-auth-keys.ts b/src/agents/live-auth-keys.ts index d2d7251323c..5538a13ef1c 100644 --- a/src/agents/live-auth-keys.ts +++ b/src/agents/live-auth-keys.ts @@ -1,5 +1,8 @@ import { getProviderEnvVars } from "../secrets/provider-env-vars.js"; -import { normalizeOptionalString } from "../shared/string-coerce.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "../shared/string-coerce.js"; import { normalizeProviderId } from "./model-selection.js"; const KEY_SPLIT_RE = /[\s,;]+/g; @@ -161,7 +164,7 @@ export function collectGeminiApiKeys(): string[] { } export function isApiKeyRateLimitError(message: string): boolean { - const lower = message.toLowerCase(); + const lower = normalizeLowercaseStringOrEmpty(message); if (lower.includes("rate_limit")) { return true; } @@ -188,7 +191,7 @@ export function isAnthropicRateLimitError(message: string): boolean { } export function isAnthropicBillingError(message: string): boolean { - const lower = message.toLowerCase(); + const lower = normalizeLowercaseStringOrEmpty(message); if (lower.includes("credit balance")) { return true; } diff --git a/src/agents/media-generation-task-status-shared.ts b/src/agents/media-generation-task-status-shared.ts index a6205249a7b..6ee14a45558 100644 --- a/src/agents/media-generation-task-status-shared.ts +++ b/src/agents/media-generation-task-status-shared.ts @@ -1,3 +1,4 @@ +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import type { TaskRecord } from "../tasks/task-registry.types.js"; import { buildSessionAsyncTaskStatusDetails, @@ -89,7 +90,7 @@ export function buildActiveMediaGenerationTaskPromptContextForSession(params: { } const provider = getMediaGenerationTaskProviderId(task, params.sourcePrefix); const lines = [ - `An active ${params.nounLabel.toLowerCase()} background task already exists for this session.`, + `An active ${normalizeLowercaseStringOrEmpty(params.nounLabel)} background task already exists for this session.`, `Task ${task.taskId} is currently ${task.status}${provider ? ` via ${provider}` : ""}.`, task.progressSummary ? `Current progress: ${task.progressSummary}.` : null, `Do not call \`${params.toolName}\` again for the same request while that task is queued or running.`, diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index a67a0171e9b..fcb2bc7ff79 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -12,7 +12,10 @@ import { shouldDeferProviderSyntheticProfileAuthWithPlugin, } from "../plugins/provider-runtime.js"; import { resolveOwningPluginIdsForProvider } from "../plugins/providers.js"; -import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalLowercaseString, +} from "../shared/string-coerce.js"; import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js"; import { type AuthProfileStore, @@ -141,7 +144,7 @@ function resolveProviderAuthOverride( function isLocalBaseUrl(baseUrl: string): boolean { try { - const host = new URL(baseUrl).hostname.toLowerCase(); + const host = normalizeLowercaseStringOrEmpty(new URL(baseUrl).hostname); return ( host === "localhost" || host === "127.0.0.1" || diff --git a/src/agents/model-scan.ts b/src/agents/model-scan.ts index 570837882a6..f40bf9d04f3 100644 --- a/src/agents/model-scan.ts +++ b/src/agents/model-scan.ts @@ -10,6 +10,7 @@ import { import { Type } from "@sinclair/typebox"; import { formatErrorMessage } from "../infra/errors.js"; import { inferParamBFromIdOrName } from "../shared/model-param-b.js"; +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { normalizeProviderId } from "./provider-id.js"; const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models"; @@ -104,7 +105,7 @@ function parseModality(modality: string | null): Array<"text" | "image"> { if (!modality) { return ["text"]; } - const normalized = modality.toLowerCase(); + const normalized = normalizeLowercaseStringOrEmpty(modality); const parts = normalized.split(/[^a-z]+/).filter(Boolean); const hasImage = parts.includes("image"); return hasImage ? ["text", "image"] : ["text"]; diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index 2eb340ec418..c5981065885 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -176,7 +176,7 @@ export function inferUniqueProviderFromConfiguredModels(params: { if (!model) { return undefined; } - const normalized = model.toLowerCase(); + const normalized = normalizeLowercaseStringOrEmpty(model); const providers = new Set(); const addProvider = (provider: string) => { const normalizedProvider = normalizeProviderId(provider); @@ -717,20 +717,22 @@ export function resolveThinkingDefault(params: { catalog?: ModelCatalogEntry[]; }): ThinkLevel { const normalizedProvider = normalizeProviderId(params.provider); - const normalizedModel = params.model.toLowerCase().replace(/\./g, "-"); + const normalizedModel = normalizeLowercaseStringOrEmpty(params.model).replace(/\./g, "-"); const catalogCandidate = params.catalog?.find( (entry) => entry.provider === params.provider && entry.id === params.model, ); const configuredModels = params.cfg.agents?.defaults?.models; const canonicalKey = modelKey(params.provider, params.model); const legacyKey = legacyModelKey(params.provider, params.model); + const normalizedCanonicalKey = normalizeLowercaseStringOrEmpty(canonicalKey); + const normalizedLegacyKey = normalizeOptionalLowercaseString(legacyKey); const primarySelection = normalizeModelSelection(params.cfg.agents?.defaults?.model); const normalizedPrimarySelection = normalizeOptionalLowercaseString(primarySelection); const explicitModelConfigured = (configuredModels ? canonicalKey in configuredModels : false) || Boolean(legacyKey && configuredModels && legacyKey in configuredModels) || - normalizedPrimarySelection === canonicalKey.toLowerCase() || - Boolean(legacyKey && normalizedPrimarySelection === legacyKey.toLowerCase()) || + normalizedPrimarySelection === normalizedCanonicalKey || + Boolean(normalizedLegacyKey && normalizedPrimarySelection === normalizedLegacyKey) || normalizedPrimarySelection === normalizeLowercaseStringOrEmpty(params.model); const perModelThinking = configuredModels?.[canonicalKey]?.params?.thinking ?? diff --git a/src/agents/payload-redaction.ts b/src/agents/payload-redaction.ts index 71bc3857ef8..57675ccb267 100644 --- a/src/agents/payload-redaction.ts +++ b/src/agents/payload-redaction.ts @@ -14,7 +14,7 @@ const NON_CREDENTIAL_FIELD_NAMES = new Set([ ]); function normalizeFieldName(value: string): string { - return value.replaceAll(/[^a-z0-9]/gi, "").toLowerCase(); + return normalizeLowercaseStringOrEmpty(value.replaceAll(/[^a-z0-9]/gi, "")); } function isCredentialFieldName(key: string): boolean { diff --git a/src/agents/pi-embedded-runner/google-stream-wrappers.ts b/src/agents/pi-embedded-runner/google-stream-wrappers.ts index 7d01796a858..be7893f4c60 100644 --- a/src/agents/pi-embedded-runner/google-stream-wrappers.ts +++ b/src/agents/pi-embedded-runner/google-stream-wrappers.ts @@ -5,7 +5,7 @@ import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; import { streamWithPayloadPatch } from "./stream-payload-utils.js"; function isGemini31Model(modelId: string): boolean { - const normalized = modelId.toLowerCase(); + const normalized = normalizeLowercaseStringOrEmpty(modelId); return normalized.includes("gemini-3.1-pro") || normalized.includes("gemini-3.1-flash"); } diff --git a/src/agents/pi-embedded-subscribe.handlers.tools.ts b/src/agents/pi-embedded-subscribe.handlers.tools.ts index 8bc47eff622..9c7ca07582b 100644 --- a/src/agents/pi-embedded-subscribe.handlers.tools.ts +++ b/src/agents/pi-embedded-subscribe.handlers.tools.ts @@ -991,7 +991,9 @@ export async function handleToolExecutionEnd( const approvalData: AgentApprovalEventData = { phase: "resolved", kind: "exec", - status: parsedApprovalResult.metadata.toLowerCase().includes("approval-request-failed") + status: normalizeOptionalLowercaseString(parsedApprovalResult.metadata)?.includes( + "approval-request-failed", + ) ? "failed" : "denied", title: "Command approval resolved", diff --git a/src/agents/pi-embedded-subscribe.tools.ts b/src/agents/pi-embedded-subscribe.tools.ts index 3306dcbe684..904c8e22282 100644 --- a/src/agents/pi-embedded-subscribe.tools.ts +++ b/src/agents/pi-embedded-subscribe.tools.ts @@ -403,7 +403,7 @@ export function extractMessagingToolSend( const channelRaw = typeof args.channel === "string" ? args.channel.trim() : ""; const providerHint = providerRaw || channelRaw; const providerId = providerHint ? normalizeChannelId(providerHint) : null; - const provider = providerId ?? (providerHint ? providerHint.toLowerCase() : "message"); + const provider = providerId ?? normalizeOptionalLowercaseString(providerHint) ?? "message"; const to = normalizeTargetForProvider(provider, toRaw); return to ? { tool: toolName, provider, accountId, to } : undefined; } diff --git a/src/agents/prompt-cache-stability.ts b/src/agents/prompt-cache-stability.ts index a2966b85ba5..ed265ecd5a0 100644 --- a/src/agents/prompt-cache-stability.ts +++ b/src/agents/prompt-cache-stability.ts @@ -1,3 +1,5 @@ +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; + export function normalizeStructuredPromptSection(text: string): string { return text .replace(/\r\n?/g, "\n") @@ -9,7 +11,7 @@ export function normalizePromptCapabilityIds(capabilities: ReadonlyArray const seen = new Set(); const normalized: string[] = []; for (const capability of capabilities) { - const value = normalizeStructuredPromptSection(capability).toLowerCase(); + const value = normalizeLowercaseStringOrEmpty(normalizeStructuredPromptSection(capability)); if (!value || seen.has(value)) { continue; } diff --git a/src/agents/provider-request-config.ts b/src/agents/provider-request-config.ts index 93e837b54e9..79dbcb14ef1 100644 --- a/src/agents/provider-request-config.ts +++ b/src/agents/provider-request-config.ts @@ -3,6 +3,7 @@ import type { ModelDefinitionConfig } from "../config/types.js"; import type { ConfiguredModelProviderRequest } from "../config/types.provider-request.js"; import { assertSecretInputResolved } from "../config/types.secrets.js"; import type { PinnedDispatcherPolicy } from "../infra/net/ssrf.js"; +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import type { ProviderRequestCapabilities, ProviderRequestCapability, @@ -358,7 +359,7 @@ export function mergeProviderRequestHeaders( merged = Object.create(null) as Record; } for (const [key, value] of Object.entries(headers)) { - const normalizedKey = key.toLowerCase(); + const normalizedKey = normalizeLowercaseStringOrEmpty(key); if (FORBIDDEN_HEADER_KEYS.has(normalizedKey)) { continue; } @@ -496,12 +497,12 @@ function applyResolvedAuthHeader( return headers; } const next = mergeProviderRequestHeaders(headers) ?? Object.create(null); - const keysToDelete = new Set([auth.headerName.toLowerCase()]); + const keysToDelete = new Set([normalizeLowercaseStringOrEmpty(auth.headerName)]); if (auth.mode === "header") { keysToDelete.add("authorization"); } for (const key of Object.keys(next)) { - if (keysToDelete.has(key.toLowerCase())) { + if (keysToDelete.has(normalizeLowercaseStringOrEmpty(key))) { delete next[key]; } } @@ -601,12 +602,12 @@ export function resolveProviderRequestPolicyConfig( auth, ); const protectedAttributionKeys = new Set( - Object.keys(policy.attributionHeaders ?? {}).map((key) => key.toLowerCase()), + Object.keys(policy.attributionHeaders ?? {}).map((key) => normalizeLowercaseStringOrEmpty(key)), ); const unprotectedCallerHeaders = params.callerHeaders ? Object.fromEntries( Object.entries(params.callerHeaders).filter( - ([key]) => !protectedAttributionKeys.has(key.toLowerCase()), + ([key]) => !protectedAttributionKeys.has(normalizeLowercaseStringOrEmpty(key)), ), ) : undefined; diff --git a/src/agents/skills-install-download.ts b/src/agents/skills-install-download.ts index d03d765d1ca..128fd3ddcc5 100644 --- a/src/agents/skills-install-download.ts +++ b/src/agents/skills-install-download.ts @@ -48,7 +48,10 @@ function resolveArchiveType(spec: SkillInstallSpec, filename: string): string | if (explicit) { return explicit; } - const lower = filename.toLowerCase(); + const lower = normalizeOptionalLowercaseString(filename); + if (!lower) { + return undefined; + } if (lower.endsWith(".tar.gz") || lower.endsWith(".tgz")) { return "tar.gz"; } diff --git a/src/agents/skills.ts b/src/agents/skills.ts index 455e39ff089..c9c65449f5a 100644 --- a/src/agents/skills.ts +++ b/src/agents/skills.ts @@ -1,4 +1,5 @@ import type { OpenClawConfig } from "../config/config.js"; +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import type { SkillsInstallPreferences } from "./skills/types.js"; export { @@ -38,7 +39,7 @@ export function resolveSkillsInstallPreferences(config?: OpenClawConfig): Skills const raw = config?.skills?.install; const preferBrew = raw?.preferBrew ?? true; const managerRaw = typeof raw?.nodeManager === "string" ? raw.nodeManager.trim() : ""; - const manager = managerRaw.toLowerCase(); + const manager = normalizeLowercaseStringOrEmpty(managerRaw); const nodeManager: SkillsInstallPreferences["nodeManager"] = manager === "pnpm" || manager === "yarn" || manager === "bun" || manager === "npm" ? manager diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index 6a623eac1be..2a20fa791a1 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -7,7 +7,10 @@ import { normalizeAgentId, parseAgentSessionKey, } from "../routing/session-key.js"; -import { normalizeOptionalString } from "../shared/string-coerce.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "../shared/string-coerce.js"; import type { BootstrapContextMode } from "./bootstrap-files.js"; import { mapToolContextToSpawnedRunMetadata, @@ -452,11 +455,11 @@ export async function spawnSubagentDirect( cfg?.agents?.defaults?.subagents?.allowAgents ?? []; const allowAny = allowAgents.some((value) => value.trim() === "*"); - const normalizedTargetId = targetAgentId.toLowerCase(); + const normalizedTargetId = normalizeLowercaseStringOrEmpty(targetAgentId); const allowSet = new Set( allowAgents .filter((value) => value.trim() && value.trim() !== "*") - .map((value) => normalizeAgentId(value).toLowerCase()), + .map((value) => normalizeLowercaseStringOrEmpty(normalizeAgentId(value))), ); if (!allowAny && !allowSet.has(normalizedTargetId)) { const allowedText = allowSet.size > 0 ? Array.from(allowSet).join(", ") : "none"; diff --git a/src/agents/tool-mutation.ts b/src/agents/tool-mutation.ts index 9bb0fbb81d0..d9349ba4654 100644 --- a/src/agents/tool-mutation.ts +++ b/src/agents/tool-mutation.ts @@ -70,10 +70,10 @@ function normalizeActionName(value: unknown): string | undefined { function normalizeFingerprintValue(value: unknown): string | undefined { if (typeof value === "string") { const normalized = value.trim(); - return normalized ? normalized.toLowerCase() : undefined; + return normalized ? normalizeLowercaseStringOrEmpty(normalized) : undefined; } if (typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") { - return String(value).toLowerCase(); + return normalizeLowercaseStringOrEmpty(String(value)); } return undefined; } @@ -189,7 +189,7 @@ export function buildToolActionFingerprint( 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(); + const normalizedMeta = normalizeOptionalLowercaseString(meta?.trim().replace(/\s+/g, " ")); // 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. diff --git a/src/agents/tools/canvas-tool.ts b/src/agents/tools/canvas-tool.ts index 7db4aad515a..58482171094 100644 --- a/src/agents/tools/canvas-tool.ts +++ b/src/agents/tools/canvas-tool.ts @@ -9,6 +9,7 @@ import { logVerbose, shouldLogVerbose } from "../../globals.js"; import { isInboundPathAllowed } from "../../media/inbound-path-policy.js"; import { getDefaultMediaLocalRoots } from "../../media/local-roots.js"; import { imageMimeFromFormat } from "../../media/mime.js"; +import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; import { resolveImageSanitizationLimits } from "../image-sanitization.js"; import { optionalStringEnum, stringEnum } from "../schema/typebox.js"; import { type AnyAgentTool, imageResult, jsonResult, readStringParam } from "./common.js"; @@ -160,8 +161,7 @@ export function createCanvasTool(options?: { config?: OpenClawConfig }): AnyAgen return jsonResult({ ok: true }); } case "snapshot": { - const formatRaw = - typeof params.outputFormat === "string" ? params.outputFormat.toLowerCase() : "png"; + const formatRaw = normalizeLowercaseStringOrEmpty(params.outputFormat) || "png"; const format = formatRaw === "jpg" || formatRaw === "jpeg" ? "jpeg" : "png"; const maxWidth = typeof params.maxWidth === "number" && Number.isFinite(params.maxWidth) diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index 513656a7d44..ed79e5b6122 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -361,7 +361,7 @@ async function buildReminderContextLines(params: { } function stripThreadSuffixFromSessionKey(sessionKey: string): string { - const normalized = sessionKey.toLowerCase(); + const normalized = normalizeLowercaseStringOrEmpty(sessionKey); const idx = normalized.lastIndexOf(":thread:"); if (idx <= 0) { return sessionKey; diff --git a/src/agents/tools/gateway.ts b/src/agents/tools/gateway.ts index 41b377a436b..fdcb55ae830 100644 --- a/src/agents/tools/gateway.ts +++ b/src/agents/tools/gateway.ts @@ -6,6 +6,7 @@ import { type OperatorScope, } from "../../gateway/method-scopes.js"; import { formatErrorMessage } from "../../infra/errors.js"; +import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js"; import { readStringParam } from "./common.js"; @@ -53,7 +54,7 @@ function canonicalizeToolGatewayWsUrl(raw: string): { origin: string; key: strin const origin = url.origin; // Key: protocol + host only, lowercased. (host includes IPv6 brackets + port when present) - const key = `${url.protocol}//${url.host.toLowerCase()}`; + const key = `${url.protocol}//${normalizeLowercaseStringOrEmpty(url.host)}`; return { origin, key }; } diff --git a/src/agents/tools/nodes-tool-media.ts b/src/agents/tools/nodes-tool-media.ts index f58cba84beb..5203f1d4df1 100644 --- a/src/agents/tools/nodes-tool-media.ts +++ b/src/agents/tools/nodes-tool-media.ts @@ -15,6 +15,7 @@ import { } from "../../cli/nodes-screen.js"; import { parseDurationMs } from "../../cli/parse-duration.js"; import { imageMimeFromFormat } from "../../media/mime.js"; +import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; import type { ImageSanitizationLimits } from "../image-sanitization.js"; import { sanitizeToolResultImages } from "../tool-images.js"; import type { GatewayCallOptions } from "./gateway.js"; @@ -62,7 +63,7 @@ async function executeCameraSnap({ const node = requireString(params, "node"); const resolvedNode = await resolveNode(gatewayOpts, node); const nodeId = resolvedNode.nodeId; - const facingRaw = typeof params.facing === "string" ? params.facing.toLowerCase() : "front"; + const facingRaw = normalizeLowercaseStringOrEmpty(params.facing) || "front"; const facings: CameraFacing[] = facingRaw === "both" ? ["front", "back"] @@ -107,7 +108,7 @@ async function executeCameraSnap({ idempotencyKey: crypto.randomUUID(), }); const payload = parseCameraSnapPayload(raw?.payload); - const normalizedFormat = payload.format.toLowerCase(); + const normalizedFormat = normalizeLowercaseStringOrEmpty(payload.format); if (normalizedFormat !== "jpg" && normalizedFormat !== "jpeg" && normalizedFormat !== "png") { throw new Error(`unsupported camera.snap format: ${payload.format}`); } @@ -210,7 +211,7 @@ async function executePhotosLatest({ for (const [index, photoRaw] of photos.entries()) { const photo = parseCameraSnapPayload(photoRaw); - const normalizedFormat = photo.format.toLowerCase(); + const normalizedFormat = normalizeLowercaseStringOrEmpty(photo.format); if (normalizedFormat !== "jpg" && normalizedFormat !== "jpeg" && normalizedFormat !== "png") { throw new Error(`unsupported photos.latest format: ${photo.format}`); } @@ -272,7 +273,7 @@ async function executeCameraClip({ const node = requireString(params, "node"); const resolvedNode = await resolveNode(gatewayOpts, node); const nodeId = resolvedNode.nodeId; - const facing = typeof params.facing === "string" ? params.facing.toLowerCase() : "front"; + const facing = normalizeLowercaseStringOrEmpty(params.facing) || "front"; if (facing !== "front" && facing !== "back") { throw new Error("invalid facing (front|back)"); } diff --git a/src/agents/tools/nodes-tool.ts b/src/agents/tools/nodes-tool.ts index 28bd3eb97c5..b6484318e3c 100644 --- a/src/agents/tools/nodes-tool.ts +++ b/src/agents/tools/nodes-tool.ts @@ -4,6 +4,7 @@ import type { OpenClawConfig } from "../../config/config.js"; import type { OperatorScope } from "../../gateway/method-scopes.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { resolveNodePairApprovalScopes } from "../../infra/node-pairing-authz.js"; +import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; import type { GatewayMessageChannel } from "../../utils/message-channel.js"; import { resolveSessionAgentId } from "../agent-scope.js"; import { resolveImageSanitizationLimits } from "../image-sanitization.js"; @@ -73,7 +74,7 @@ async function resolveNodePairApproveScopes( } function isPairingRequiredMessage(message: string): boolean { - const lower = message.toLowerCase(); + const lower = normalizeLowercaseStringOrEmpty(message); return lower.includes("pairing required") || lower.includes("not_paired"); } diff --git a/src/agents/tools/nodes-utils.ts b/src/agents/tools/nodes-utils.ts index beee8c91a33..b67915706be 100644 --- a/src/agents/tools/nodes-utils.ts +++ b/src/agents/tools/nodes-utils.ts @@ -40,7 +40,7 @@ function messageFromError(error: unknown): string { } function shouldFallbackToPairList(error: unknown): boolean { - const message = messageFromError(error).toLowerCase(); + const message = normalizeOptionalLowercaseString(messageFromError(error)) ?? ""; if (!message.includes("node.list")) { return false; } diff --git a/src/agents/tools/web-fetch-utils.ts b/src/agents/tools/web-fetch-utils.ts index 86d03650eb6..01fc1beada8 100644 --- a/src/agents/tools/web-fetch-utils.ts +++ b/src/agents/tools/web-fetch-utils.ts @@ -1,3 +1,4 @@ +import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; import { sanitizeHtml, stripInvisibleUnicode } from "./web-fetch-visibility.js"; export type ExtractMode = "markdown" | "text"; @@ -169,7 +170,7 @@ function exceedsEstimatedHtmlNestingDepth(html: string, maxDepth: number): boole j += 1; } - const tagName = html.slice(nameStart, j).toLowerCase(); + const tagName = normalizeLowercaseStringOrEmpty(html.slice(nameStart, j)); if (!tagName) { continue; } diff --git a/src/agents/tools/web-fetch-visibility.ts b/src/agents/tools/web-fetch-visibility.ts index 6a0838ae32d..7c7aa30c849 100644 --- a/src/agents/tools/web-fetch-visibility.ts +++ b/src/agents/tools/web-fetch-visibility.ts @@ -1,4 +1,7 @@ -import { normalizeOptionalLowercaseString } from "../../shared/string-coerce.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalLowercaseString, +} from "../../shared/string-coerce.js"; // CSS property values that indicate an element is hidden const HIDDEN_STYLE_PATTERNS: Array<[string, RegExp]> = [ @@ -24,7 +27,7 @@ const HIDDEN_CLASS_NAMES = new Set([ ]); function hasHiddenClass(className: string): boolean { - const classes = className.toLowerCase().split(/\s+/); + const classes = normalizeLowercaseStringOrEmpty(className).split(/\s+/); return classes.some((cls) => HIDDEN_CLASS_NAMES.has(cls)); } @@ -88,7 +91,7 @@ function isStyleHidden(style: string): boolean { } function shouldRemoveElement(element: Element): boolean { - const tagName = element.tagName.toLowerCase(); + const tagName = normalizeLowercaseStringOrEmpty(element.tagName); // Always-remove tags if (["meta", "template", "svg", "canvas", "iframe", "object", "embed"].includes(tagName)) { diff --git a/src/agents/tools/web-fetch.ts b/src/agents/tools/web-fetch.ts index 54b6d3d9354..faae39c38c5 100644 --- a/src/agents/tools/web-fetch.ts +++ b/src/agents/tools/web-fetch.ts @@ -5,6 +5,7 @@ import { logDebug } from "../../logger.js"; import type { RuntimeWebFetchMetadata } from "../../secrets/runtime-web-tools.types.js"; import { wrapExternalContent, wrapWebContent } from "../../security/external-content.js"; import { + normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, normalizeOptionalString, } from "../../shared/string-coerce.js"; @@ -130,7 +131,7 @@ function looksLikeHtml(value: string): boolean { if (!trimmed) { return false; } - const head = trimmed.slice(0, 256).toLowerCase(); + const head = normalizeLowercaseStringOrEmpty(trimmed.slice(0, 256)); return head.startsWith("