From 848f154f3eaa0a55b2624afbc994b5bd38240864 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Apr 2026 21:22:17 +0100 Subject: [PATCH] refactor: share tool call transcript helpers --- src/agents/session-transcript-repair.ts | 72 ++++--------------------- src/agents/tool-call-id.ts | 71 +++--------------------- src/agents/tool-call-shared.ts | 67 +++++++++++++++++++++++ 3 files changed, 83 insertions(+), 127 deletions(-) create mode 100644 src/agents/tool-call-shared.ts diff --git a/src/agents/session-transcript-repair.ts b/src/agents/session-transcript-repair.ts index 75952ee3f38..2260a29b1f0 100644 --- a/src/agents/session-transcript-repair.ts +++ b/src/agents/session-transcript-repair.ts @@ -5,11 +5,15 @@ import { readStringValue, } from "../shared/string-coerce.js"; import { extractToolCallsFromAssistant, extractToolResultId } from "./tool-call-id.js"; +import { + REDACTED_SESSIONS_SPAWN_ATTACHMENT_CONTENT, + SESSIONS_SPAWN_ATTACHMENT_METADATA_KEYS, + isAllowedToolCallName, + isRedactedSessionsSpawnAttachment, + normalizeAllowedToolNames, +} from "./tool-call-shared.js"; -const TOOL_CALL_NAME_MAX_CHARS = 64; -const TOOL_CALL_NAME_RE = /^[A-Za-z0-9_:.-]+$/; -const REDACTED_SESSIONS_SPAWN_ATTACHMENT_CONTENT = "__OPENCLAW_REDACTED__"; -const SESSIONS_SPAWN_ATTACHMENT_METADATA_KEYS = ["name", "encoding", "mimeType"] as const; +export { isRedactedSessionsSpawnAttachment } from "./tool-call-shared.js"; type RawToolCallBlock = { type?: unknown; @@ -53,40 +57,6 @@ function hasToolCallId(block: RawToolCallBlock): boolean { return hasNonEmptyStringField(block.id); } -function normalizeAllowedToolNames(allowedToolNames?: Iterable): Set | null { - if (!allowedToolNames) { - return null; - } - const normalized = new Set(); - for (const name of allowedToolNames) { - if (typeof name !== "string") { - continue; - } - const trimmed = name.trim(); - if (trimmed) { - normalized.add(normalizeLowercaseStringOrEmpty(trimmed)); - } - } - return normalized.size > 0 ? normalized : null; -} - -function hasToolCallName(block: RawToolCallBlock, allowedToolNames: Set | null): boolean { - if (typeof block.name !== "string") { - return false; - } - const trimmed = block.name.trim(); - if (!trimmed) { - return false; - } - if (trimmed.length > TOOL_CALL_NAME_MAX_CHARS || !TOOL_CALL_NAME_RE.test(trimmed)) { - return false; - } - if (!allowedToolNames) { - return true; - } - return allowedToolNames.has(normalizeLowercaseStringOrEmpty(trimmed)); -} - function redactSessionsSpawnAttachmentsArgs(value: unknown): unknown { if (!value || typeof value !== "object") { return value; @@ -127,28 +97,6 @@ function redactSessionsSpawnAttachment(item: unknown): Record { return next; } -export function isRedactedSessionsSpawnAttachment(item: unknown): boolean { - if (!item || typeof item !== "object") { - return false; - } - const attachment = item as Record; - if (attachment.content !== REDACTED_SESSIONS_SPAWN_ATTACHMENT_CONTENT) { - return false; - } - for (const key of Object.keys(attachment)) { - if (key === "content") { - continue; - } - if (!(SESSIONS_SPAWN_ATTACHMENT_METADATA_KEYS as readonly string[]).includes(key)) { - return false; - } - if (typeof attachment[key] !== "string" || attachment[key].trim().length === 0) { - return false; - } - } - return true; -} - function sanitizeToolCallBlock(block: RawToolCallBlock): RawToolCallBlock { const rawName = readStringValue(block.name); const trimmedName = rawName?.trim(); @@ -212,7 +160,7 @@ function isReplaySafeThinkingAssistantTurn( !hasToolCallInput(block) || !toolCallId || seenToolCallIds.has(toolCallId) || - !hasToolCallName(block, allowedToolNames) + !isAllowedToolCallName(block.name, allowedToolNames) ) { return false; } @@ -364,7 +312,7 @@ export function repairToolCallInputs( isRawToolCallBlock(block) && (!hasToolCallInput(block) || !hasToolCallId(block) || - !hasToolCallName(block, allowedToolNames)) + !isAllowedToolCallName((block as RawToolCallBlock).name, allowedToolNames)) ) { droppedToolCalls += 1; droppedInMessage += 1; diff --git a/src/agents/tool-call-id.ts b/src/agents/tool-call-id.ts index bcaf5007980..1d0b9f87578 100644 --- a/src/agents/tool-call-id.ts +++ b/src/agents/tool-call-id.ts @@ -1,13 +1,14 @@ import { createHash } from "node:crypto"; import type { AgentMessage } from "@mariozechner/pi-agent-core"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; +import { + isAllowedToolCallName, + isRedactedSessionsSpawnAttachment, + normalizeAllowedToolNames, +} from "./tool-call-shared.js"; export type ToolCallIdMode = "strict" | "strict9"; const NATIVE_ANTHROPIC_TOOL_USE_ID_RE = /^toolu_[A-Za-z0-9_]+$/; -const REDACTED_SESSIONS_SPAWN_ATTACHMENT_CONTENT = "__OPENCLAW_REDACTED__"; -const SESSIONS_SPAWN_ATTACHMENT_METADATA_KEYS = ["name", "encoding", "mimeType"] as const; -const TOOL_CALL_NAME_MAX_CHARS = 64; -const TOOL_CALL_NAME_RE = /^[A-Za-z0-9_:.-]+$/; const STRICT9_LEN = 9; const TOOL_CALL_TYPES = new Set(["toolCall", "toolUse", "functionCall"]); @@ -111,46 +112,6 @@ function hasToolCallInput(block: ReplaySafeToolCallBlock): boolean { return hasInput || hasArguments; } -function normalizeAllowedToolNames(allowedToolNames?: Iterable): Set | null { - if (!allowedToolNames) { - return null; - } - const normalized = new Set(); - for (const name of allowedToolNames) { - if (typeof name !== "string") { - continue; - } - const trimmed = name.trim(); - if (!trimmed) { - continue; - } - normalized.add(normalizeLowercaseStringOrEmpty(trimmed)); - } - return normalized.size > 0 ? normalized : null; -} - -function isRedactedSessionsSpawnAttachment(item: unknown): boolean { - if (!item || typeof item !== "object") { - return false; - } - const attachment = item as Record; - if (attachment.content !== REDACTED_SESSIONS_SPAWN_ATTACHMENT_CONTENT) { - return false; - } - for (const key of Object.keys(attachment)) { - if (key === "content") { - continue; - } - if (!(SESSIONS_SPAWN_ATTACHMENT_METADATA_KEYS as readonly string[]).includes(key)) { - return false; - } - if (typeof attachment[key] !== "string" || attachment[key].trim().length === 0) { - return false; - } - } - return true; -} - function toolCallNeedsReplayMutation(block: ReplaySafeToolCallBlock): boolean { const rawName = typeof block.name === "string" ? block.name : undefined; const trimmedName = rawName?.trim(); @@ -177,26 +138,6 @@ function toolCallNeedsReplayMutation(block: ReplaySafeToolCallBlock): boolean { return false; } -function hasReplaySafeToolCallName( - block: ReplaySafeToolCallBlock, - allowedToolNames: Set | null, -): boolean { - if (typeof block.name !== "string") { - return false; - } - const trimmed = block.name.trim(); - if (!trimmed) { - return false; - } - if (trimmed.length > TOOL_CALL_NAME_MAX_CHARS || !TOOL_CALL_NAME_RE.test(trimmed)) { - return false; - } - if (!allowedToolNames) { - return true; - } - return allowedToolNames.has(normalizeLowercaseStringOrEmpty(trimmed)); -} - function isReplaySafeThinkingAssistantMessage( message: Extract, allowedToolNames: Set | null, @@ -227,7 +168,7 @@ function isReplaySafeThinkingAssistantMessage( !hasToolCallInput(typedBlock) || !toolCallId || seenToolCallIds.has(toolCallId) || - !hasReplaySafeToolCallName(typedBlock, allowedToolNames) || + !isAllowedToolCallName(typedBlock.name, allowedToolNames) || toolCallNeedsReplayMutation(typedBlock) ) { return false; diff --git a/src/agents/tool-call-shared.ts b/src/agents/tool-call-shared.ts new file mode 100644 index 00000000000..4a45c595334 --- /dev/null +++ b/src/agents/tool-call-shared.ts @@ -0,0 +1,67 @@ +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; + +export const TOOL_CALL_NAME_MAX_CHARS = 64; +export const TOOL_CALL_NAME_RE = /^[A-Za-z0-9_:.-]+$/; + +export const REDACTED_SESSIONS_SPAWN_ATTACHMENT_CONTENT = "__OPENCLAW_REDACTED__"; +export const SESSIONS_SPAWN_ATTACHMENT_METADATA_KEYS = ["name", "encoding", "mimeType"] as const; + +export function normalizeAllowedToolNames(allowedToolNames?: Iterable): Set | null { + if (!allowedToolNames) { + return null; + } + const normalized = new Set(); + for (const name of allowedToolNames) { + if (typeof name !== "string") { + continue; + } + const trimmed = name.trim(); + if (!trimmed) { + continue; + } + normalized.add(normalizeLowercaseStringOrEmpty(trimmed)); + } + return normalized.size > 0 ? normalized : null; +} + +export function isAllowedToolCallName( + name: unknown, + allowedToolNames: Set | null, +): boolean { + if (typeof name !== "string") { + return false; + } + const trimmed = name.trim(); + if (!trimmed) { + return false; + } + if (trimmed.length > TOOL_CALL_NAME_MAX_CHARS || !TOOL_CALL_NAME_RE.test(trimmed)) { + return false; + } + if (!allowedToolNames) { + return true; + } + return allowedToolNames.has(normalizeLowercaseStringOrEmpty(trimmed)); +} + +export function isRedactedSessionsSpawnAttachment(item: unknown): boolean { + if (!item || typeof item !== "object") { + return false; + } + const attachment = item as Record; + if (attachment.content !== REDACTED_SESSIONS_SPAWN_ATTACHMENT_CONTENT) { + return false; + } + for (const key of Object.keys(attachment)) { + if (key === "content") { + continue; + } + if (!(SESSIONS_SPAWN_ATTACHMENT_METADATA_KEYS as readonly string[]).includes(key)) { + return false; + } + if (typeof attachment[key] !== "string" || attachment[key].trim().length === 0) { + return false; + } + } + return true; +}