refactor(plugin-sdk): extract shared dedupe helpers

This commit is contained in:
Peter Steinberger
2026-04-29 12:14:32 +01:00
parent dce2513db2
commit 81ad827380
7 changed files with 705 additions and 142 deletions

View File

@@ -1,2 +1,2 @@
244286f93cd42484f81061b672437d7a769a61864c72eb3795f9e7abc739f60b plugin-sdk-api-baseline.json
5b7e45d83a0a7862f26f59a32647cdb04289609419e61473284568dd3adf9736 plugin-sdk-api-baseline.jsonl
c14ed336d7add0044299560f2fb2fa9272f23aae335799313f32c63521edc24e plugin-sdk-api-baseline.json
e096b25bd16bf1b0562a783609e9f7d945b6e29560ef8ad3fb433145fe084a5d plugin-sdk-api-baseline.jsonl

View File

@@ -15,4 +15,5 @@ export {
} from "./browser-profiles.js";
export { parseBrowserHttpUrl, redactCdpUrl } from "./browser-cdp.js";
export { ensureBrowserControlAuth, resolveBrowserControlAuth } from "./browser-control-auth.js";
export { movePathToTrash, type MovePathToTrashOptions } from "./browser-trash.js";
export type { BrowserControlAuth } from "./browser-control-auth.js";

View File

@@ -1,7 +1,5 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { loadBundledPluginPublicSurfaceModuleSync } from "./facade-loader.js";
export { movePathToTrash, type MovePathToTrashOptions } from "./browser-trash.js";
type CloseTrackedBrowserTabsParams = {
sessionKeys: Array<string | undefined>;
@@ -14,8 +12,6 @@ type BrowserMaintenanceSurface = {
};
let cachedBrowserMaintenanceSurface: BrowserMaintenanceSurface | undefined;
const TRASH_DESTINATION_COLLISION_CODES = new Set(["EEXIST", "ENOTEMPTY", "ERR_FS_CP_EEXIST"]);
const TRASH_DESTINATION_RETRY_LIMIT = 4;
function hasRequestedSessionKeys(sessionKeys: Array<string | undefined>): boolean {
return sessionKeys.some((key) => Boolean(key?.trim()));
@@ -30,125 +26,6 @@ function loadBrowserMaintenanceSurface(): BrowserMaintenanceSurface {
return cachedBrowserMaintenanceSurface;
}
function getFsErrorCode(error: unknown): string | undefined {
if (!error || typeof error !== "object" || !("code" in error)) {
return undefined;
}
const code = (error as NodeJS.ErrnoException).code;
return typeof code === "string" ? code : undefined;
}
function isTrashDestinationCollision(error: unknown): boolean {
const code = getFsErrorCode(error);
return Boolean(code && TRASH_DESTINATION_COLLISION_CODES.has(code));
}
function isSameOrChildPath(candidate: string, parent: string): boolean {
return candidate === parent || candidate.startsWith(`${parent}${path.sep}`);
}
function resolveAllowedTrashRoots(): string[] {
const roots = [os.homedir(), os.tmpdir()].map((root) => {
try {
return path.resolve(fs.realpathSync.native(root));
} catch {
return path.resolve(root);
}
});
return [...new Set(roots)];
}
function assertAllowedTrashTarget(targetPath: string): void {
let resolvedTargetPath = path.resolve(targetPath);
try {
resolvedTargetPath = path.resolve(fs.realpathSync.native(targetPath));
} catch {
// The subsequent move will surface missing or inaccessible targets.
}
const isAllowed = resolveAllowedTrashRoots().some(
(root) => resolvedTargetPath !== root && isSameOrChildPath(resolvedTargetPath, root),
);
if (!isAllowed) {
throw new Error(`Refusing to trash path outside allowed roots: ${targetPath}`);
}
}
function resolveTrashDir(): string {
const homeDir = os.homedir();
const trashDir = path.join(homeDir, ".Trash");
fs.mkdirSync(trashDir, { recursive: true, mode: 0o700 });
const trashDirStat = fs.lstatSync(trashDir);
if (!trashDirStat.isDirectory() || trashDirStat.isSymbolicLink()) {
throw new Error(`Refusing to use non-directory/symlink trash directory: ${trashDir}`);
}
const realHome = path.resolve(fs.realpathSync.native(homeDir));
const resolvedTrashDir = path.resolve(fs.realpathSync.native(trashDir));
if (resolvedTrashDir === realHome || !isSameOrChildPath(resolvedTrashDir, realHome)) {
throw new Error(`Trash directory escaped home directory: ${trashDir}`);
}
return resolvedTrashDir;
}
function trashBaseName(targetPath: string): string {
const resolvedTargetPath = path.resolve(targetPath);
if (resolvedTargetPath === path.parse(resolvedTargetPath).root) {
throw new Error(`Refusing to trash root path: ${targetPath}`);
}
const base = path.basename(resolvedTargetPath).replace(/[\\/]+/g, "");
if (!base) {
throw new Error(`Unable to derive safe trash basename for: ${targetPath}`);
}
return base;
}
function resolveContainedPath(root: string, leaf: string): string {
const resolvedRoot = path.resolve(root);
const resolvedPath = path.resolve(resolvedRoot, leaf);
if (!isSameOrChildPath(resolvedPath, resolvedRoot) || resolvedPath === resolvedRoot) {
throw new Error(`Trash destination escaped trash directory: ${resolvedPath}`);
}
return resolvedPath;
}
function reserveTrashDestination(trashDir: string, base: string, timestamp: number): string {
const containerPrefix = resolveContainedPath(trashDir, `${base}-${timestamp}-`);
const container = fs.mkdtempSync(containerPrefix);
const resolvedContainer = path.resolve(container);
const resolvedTrashDir = path.resolve(trashDir);
if (
resolvedContainer === resolvedTrashDir ||
!isSameOrChildPath(resolvedContainer, resolvedTrashDir)
) {
throw new Error(`Trash destination escaped trash directory: ${container}`);
}
return resolveContainedPath(container, base);
}
function movePathToDestination(targetPath: string, dest: string): boolean {
try {
fs.renameSync(targetPath, dest);
return true;
} catch (error) {
if (getFsErrorCode(error) !== "EXDEV") {
if (isTrashDestinationCollision(error)) {
return false;
}
throw error;
}
}
try {
fs.cpSync(targetPath, dest, { recursive: true, force: false, errorOnExist: true });
fs.rmSync(targetPath, { recursive: true, force: false });
return true;
} catch (error) {
if (isTrashDestinationCollision(error)) {
return false;
}
throw error;
}
}
export async function closeTrackedBrowserTabsForSessions(
params: CloseTrackedBrowserTabsParams,
): Promise<number> {
@@ -165,19 +42,3 @@ export async function closeTrackedBrowserTabsForSessions(
}
return await surface.closeTrackedBrowserTabsForSessions(params);
}
export async function movePathToTrash(targetPath: string): Promise<string> {
// Avoid resolving external trash helpers through the service PATH during cleanup.
const base = trashBaseName(targetPath);
assertAllowedTrashTarget(targetPath);
const trashDir = resolveTrashDir();
const timestamp = Date.now();
for (let attempt = 0; attempt < TRASH_DESTINATION_RETRY_LIMIT; attempt += 1) {
const dest = reserveTrashDestination(trashDir, base, timestamp);
if (movePathToDestination(targetPath, dest)) {
return dest;
}
}
throw new Error(`Unable to choose a unique trash destination for ${targetPath}`);
}

View File

@@ -0,0 +1,148 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
export type MovePathToTrashOptions = {
allowedRoots?: Iterable<string>;
};
const TRASH_DESTINATION_COLLISION_CODES = new Set(["EEXIST", "ENOTEMPTY", "ERR_FS_CP_EEXIST"]);
const TRASH_DESTINATION_RETRY_LIMIT = 4;
function getFsErrorCode(error: unknown): string | undefined {
if (!error || typeof error !== "object" || !("code" in error)) {
return undefined;
}
const code = (error as NodeJS.ErrnoException).code;
return typeof code === "string" ? code : undefined;
}
function isTrashDestinationCollision(error: unknown): boolean {
const code = getFsErrorCode(error);
return Boolean(code && TRASH_DESTINATION_COLLISION_CODES.has(code));
}
function isSameOrChildPath(candidate: string, parent: string): boolean {
return candidate === parent || candidate.startsWith(`${parent}${path.sep}`);
}
function resolveAllowedTrashRoots(allowedRoots?: Iterable<string>): string[] {
const roots = [...(allowedRoots ?? [os.homedir(), os.tmpdir()])].map((root) => {
try {
return path.resolve(fs.realpathSync.native(root));
} catch {
return path.resolve(root);
}
});
return [...new Set(roots)];
}
function assertAllowedTrashTarget(targetPath: string, allowedRoots?: Iterable<string>): void {
let resolvedTargetPath = path.resolve(targetPath);
try {
resolvedTargetPath = path.resolve(fs.realpathSync.native(targetPath));
} catch {
// The subsequent move will surface missing or inaccessible targets.
}
const isAllowed = resolveAllowedTrashRoots(allowedRoots).some(
(root) => resolvedTargetPath !== root && isSameOrChildPath(resolvedTargetPath, root),
);
if (!isAllowed) {
throw new Error(`Refusing to trash path outside allowed roots: ${targetPath}`);
}
}
function resolveTrashDir(): string {
const homeDir = os.homedir();
const trashDir = path.join(homeDir, ".Trash");
fs.mkdirSync(trashDir, { recursive: true, mode: 0o700 });
const trashDirStat = fs.lstatSync(trashDir);
if (!trashDirStat.isDirectory() || trashDirStat.isSymbolicLink()) {
throw new Error(`Refusing to use non-directory/symlink trash directory: ${trashDir}`);
}
const realHome = path.resolve(fs.realpathSync.native(homeDir));
const resolvedTrashDir = path.resolve(fs.realpathSync.native(trashDir));
if (resolvedTrashDir === realHome || !isSameOrChildPath(resolvedTrashDir, realHome)) {
throw new Error(`Trash directory escaped home directory: ${trashDir}`);
}
return resolvedTrashDir;
}
function trashBaseName(targetPath: string): string {
const resolvedTargetPath = path.resolve(targetPath);
if (resolvedTargetPath === path.parse(resolvedTargetPath).root) {
throw new Error(`Refusing to trash root path: ${targetPath}`);
}
const base = path.basename(resolvedTargetPath).replace(/[\\/]+/g, "");
if (!base) {
throw new Error(`Unable to derive safe trash basename for: ${targetPath}`);
}
return base;
}
function resolveContainedPath(root: string, leaf: string): string {
const resolvedRoot = path.resolve(root);
const resolvedPath = path.resolve(resolvedRoot, leaf);
if (!isSameOrChildPath(resolvedPath, resolvedRoot) || resolvedPath === resolvedRoot) {
throw new Error(`Trash destination escaped trash directory: ${resolvedPath}`);
}
return resolvedPath;
}
function reserveTrashDestination(trashDir: string, base: string, timestamp: number): string {
const containerPrefix = resolveContainedPath(trashDir, `${base}-${timestamp}-`);
const container = fs.mkdtempSync(containerPrefix);
const resolvedContainer = path.resolve(container);
const resolvedTrashDir = path.resolve(trashDir);
if (
resolvedContainer === resolvedTrashDir ||
!isSameOrChildPath(resolvedContainer, resolvedTrashDir)
) {
throw new Error(`Trash destination escaped trash directory: ${container}`);
}
return resolveContainedPath(container, base);
}
function movePathToDestination(targetPath: string, dest: string): boolean {
try {
fs.renameSync(targetPath, dest);
return true;
} catch (error) {
if (getFsErrorCode(error) !== "EXDEV") {
if (isTrashDestinationCollision(error)) {
return false;
}
throw error;
}
}
try {
fs.cpSync(targetPath, dest, { recursive: true, force: false, errorOnExist: true });
fs.rmSync(targetPath, { recursive: true, force: false });
return true;
} catch (error) {
if (isTrashDestinationCollision(error)) {
return false;
}
throw error;
}
}
export async function movePathToTrash(
targetPath: string,
options: MovePathToTrashOptions = {},
): Promise<string> {
// Avoid resolving external trash helpers through the service PATH during cleanup.
const base = trashBaseName(targetPath);
assertAllowedTrashTarget(targetPath, options.allowedRoots);
const trashDir = resolveTrashDir();
const timestamp = Date.now();
for (let attempt = 0; attempt < TRASH_DESTINATION_RETRY_LIMIT; attempt += 1) {
const dest = reserveTrashDestination(trashDir, base, timestamp);
if (movePathToDestination(targetPath, dest)) {
return dest;
}
}
throw new Error(`Unable to choose a unique trash destination for ${targetPath}`);
}

View File

@@ -96,6 +96,178 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
export type MigrationConfigPatchDetails = {
path: string[];
value: unknown;
};
class MigrationConfigPatchConflictError extends Error {
constructor(readonly reason: string) {
super(reason);
this.name = "MigrationConfigPatchConflictError";
}
}
export function readMigrationConfigPath(
root: Record<string, unknown>,
path: readonly string[],
): unknown {
let current: unknown = root;
for (const segment of path) {
if (!isRecord(current)) {
return undefined;
}
current = current[segment];
}
return current;
}
export function mergeMigrationConfigValue(left: unknown, right: unknown): unknown {
if (!isRecord(left) || !isRecord(right)) {
return structuredClone(right);
}
const next: Record<string, unknown> = { ...left };
for (const [key, value] of Object.entries(right)) {
next[key] = mergeMigrationConfigValue(next[key], value);
}
return next;
}
export function writeMigrationConfigPath(
root: Record<string, unknown>,
path: readonly string[],
value: unknown,
): void {
let current = root;
for (const segment of path.slice(0, -1)) {
const existing = current[segment];
if (!isRecord(existing)) {
current[segment] = {};
}
current = current[segment] as Record<string, unknown>;
}
const leaf = path.at(-1);
if (!leaf) {
return;
}
current[leaf] = mergeMigrationConfigValue(current[leaf], value);
}
export function hasMigrationConfigPatchConflict(
config: MigrationProviderContext["config"],
path: readonly string[],
value: unknown,
): boolean {
if (!isRecord(value)) {
return readMigrationConfigPath(config as Record<string, unknown>, path) !== undefined;
}
const existing = readMigrationConfigPath(config as Record<string, unknown>, path);
if (!isRecord(existing)) {
return false;
}
return Object.keys(value).some((key) => existing[key] !== undefined);
}
export function createMigrationConfigPatchItem(params: {
id: string;
target: string;
path: string[];
value: unknown;
message: string;
conflict?: boolean;
reason?: string;
source?: string;
details?: Record<string, unknown>;
}): MigrationItem {
return createMigrationItem({
id: params.id,
kind: "config",
action: "merge",
source: params.source,
target: params.target,
status: params.conflict ? "conflict" : "planned",
reason: params.conflict ? (params.reason ?? MIGRATION_REASON_TARGET_EXISTS) : undefined,
message: params.message,
details: { ...params.details, path: params.path, value: params.value },
});
}
export function createMigrationManualItem(params: {
id: string;
source: string;
message: string;
recommendation: string;
}): MigrationItem {
return createMigrationItem({
id: params.id,
kind: "manual",
action: "manual",
source: params.source,
status: "skipped",
message: params.message,
reason: params.recommendation,
});
}
export function readMigrationConfigPatchDetails(
item: MigrationItem,
): MigrationConfigPatchDetails | undefined {
const path = item.details?.path;
if (
!Array.isArray(path) ||
!path.every((segment): segment is string => typeof segment === "string")
) {
return undefined;
}
return { path, value: item.details?.value };
}
export async function applyMigrationConfigPatchItem(
ctx: MigrationProviderContext,
item: MigrationItem,
): Promise<MigrationItem> {
if (item.status !== "planned") {
return item;
}
const details = readMigrationConfigPatchDetails(item);
if (!details) {
return markMigrationItemError(item, "missing config patch");
}
const configApi = ctx.runtime?.config;
if (!configApi?.current || !configApi.mutateConfigFile) {
return markMigrationItemError(item, "config runtime unavailable");
}
try {
const currentConfig = configApi.current() as MigrationProviderContext["config"];
if (
!ctx.overwrite &&
hasMigrationConfigPatchConflict(currentConfig, details.path, details.value)
) {
return markMigrationItemConflict(item, MIGRATION_REASON_TARGET_EXISTS);
}
await configApi.mutateConfigFile({
base: "runtime",
afterWrite: { mode: "auto" },
mutate(draft) {
if (!ctx.overwrite && hasMigrationConfigPatchConflict(draft, details.path, details.value)) {
throw new MigrationConfigPatchConflictError(MIGRATION_REASON_TARGET_EXISTS);
}
writeMigrationConfigPath(draft as Record<string, unknown>, details.path, details.value);
},
});
return { ...item, status: "migrated" };
} catch (err) {
if (err instanceof MigrationConfigPatchConflictError) {
return markMigrationItemConflict(item, err.reason);
}
return markMigrationItemError(item, err instanceof Error ? err.message : String(err));
}
}
export function applyMigrationManualItem(item: MigrationItem): MigrationItem {
return markMigrationItemSkipped(item, item.reason ?? "manual follow-up required");
}
function isSecretReferenceLike(value: unknown): boolean {
if (!isRecord(value)) {
return false;

View File

@@ -1,5 +1,6 @@
// Public auth/onboarding helpers for provider plugins.
import path from "node:path";
import { resolveOpenClawAgentDir } from "../agents/agent-paths.js";
import { resolveApiKeyForProfile } from "../agents/auth-profiles/oauth.js";
import { resolveAuthProfileOrder } from "../agents/auth-profiles/order.js";
@@ -7,6 +8,10 @@ import { listProfilesForProvider } from "../agents/auth-profiles/profiles.js";
import { ensureAuthProfileStore } from "../agents/auth-profiles/store.js";
import { resolveEnvApiKey } from "../agents/model-auth-env.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import { loadJsonFile, saveJsonFile } from "../infra/json-file.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import { resolveProviderEndpoint } from "./provider-model-shared.js";
export type { OpenClawConfig } from "../config/config.js";
export type { SecretInput } from "../config/types.secrets.js";
@@ -87,6 +92,170 @@ export {
hasUsableOAuthCredential,
} from "../agents/auth-profiles/credential-state.js";
const COPILOT_TOKEN_URL = "https://api.github.com/copilot_internal/v2/token";
export const COPILOT_EDITOR_VERSION = "vscode/1.96.2";
export const COPILOT_USER_AGENT = "GitHubCopilotChat/0.26.7";
export const COPILOT_EDITOR_PLUGIN_VERSION = "copilot-chat/0.35.0";
export const COPILOT_GITHUB_API_VERSION = "2025-04-01";
export const DEFAULT_COPILOT_API_BASE_URL = "https://api.individual.githubcopilot.com";
export type CachedCopilotToken = {
token: string;
expiresAt: number;
updatedAt: number;
};
export function buildCopilotIdeHeaders(
params: {
includeApiVersion?: boolean;
} = {},
): Record<string, string> {
return {
"Editor-Version": COPILOT_EDITOR_VERSION,
"Editor-Plugin-Version": COPILOT_EDITOR_PLUGIN_VERSION,
"User-Agent": COPILOT_USER_AGENT,
...(params.includeApiVersion ? { "X-Github-Api-Version": COPILOT_GITHUB_API_VERSION } : {}),
};
}
function resolveCopilotTokenCachePath(env: NodeJS.ProcessEnv = process.env) {
return path.join(resolveStateDir(env), "credentials", "github-copilot.token.json");
}
function isCopilotTokenUsable(cache: CachedCopilotToken, now = Date.now()): boolean {
return cache.expiresAt - now > 5 * 60 * 1000;
}
function parseCopilotTokenResponse(value: unknown): {
token: string;
expiresAt: number;
} {
if (!value || typeof value !== "object") {
throw new Error("Unexpected response from GitHub Copilot token endpoint");
}
const asRecord = value as Record<string, unknown>;
const token = asRecord.token;
const expiresAt = asRecord.expires_at;
if (typeof token !== "string" || token.trim().length === 0) {
throw new Error("Copilot token response missing token");
}
let expiresAtMs: number;
if (typeof expiresAt === "number" && Number.isFinite(expiresAt)) {
expiresAtMs = expiresAt < 100_000_000_000 ? expiresAt * 1000 : expiresAt;
} else if (typeof expiresAt === "string" && expiresAt.trim().length > 0) {
const parsed = Number.parseInt(expiresAt, 10);
if (!Number.isFinite(parsed)) {
throw new Error("Copilot token response has invalid expires_at");
}
expiresAtMs = parsed < 100_000_000_000 ? parsed * 1000 : parsed;
} else {
throw new Error("Copilot token response missing expires_at");
}
return { token, expiresAt: expiresAtMs };
}
function resolveCopilotProxyHost(proxyEp: string): string | null {
const trimmed = proxyEp.trim();
if (!trimmed) {
return null;
}
const urlText = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
try {
const url = new URL(urlText);
if (url.protocol !== "http:" && url.protocol !== "https:") {
return null;
}
return normalizeLowercaseStringOrEmpty(url.hostname);
} catch {
return null;
}
}
export function deriveCopilotApiBaseUrlFromToken(token: string): string | null {
const trimmed = token.trim();
if (!trimmed) {
return null;
}
const match = trimmed.match(/(?:^|;)\s*proxy-ep=([^;\s]+)/i);
const proxyEp = match?.[1]?.trim();
if (!proxyEp) {
return null;
}
const proxyHost = resolveCopilotProxyHost(proxyEp);
if (!proxyHost) {
return null;
}
const host = proxyHost.replace(/^proxy\./i, "api.");
const baseUrl = `https://${host}`;
return resolveProviderEndpoint(baseUrl).endpointClass === "invalid" ? null : baseUrl;
}
export async function resolveCopilotApiToken(params: {
githubToken: string;
env?: NodeJS.ProcessEnv;
fetchImpl?: typeof fetch;
cachePath?: string;
loadJsonFileImpl?: (path: string) => unknown;
saveJsonFileImpl?: (path: string, value: CachedCopilotToken) => void;
}): Promise<{
token: string;
expiresAt: number;
source: string;
baseUrl: string;
}> {
const env = params.env ?? process.env;
const cachePath = params.cachePath?.trim() || resolveCopilotTokenCachePath(env);
const loadJsonFileFn = params.loadJsonFileImpl ?? loadJsonFile;
const saveJsonFileFn = params.saveJsonFileImpl ?? saveJsonFile;
const cached = loadJsonFileFn(cachePath) as CachedCopilotToken | undefined;
if (cached && typeof cached.token === "string" && typeof cached.expiresAt === "number") {
if (isCopilotTokenUsable(cached)) {
return {
token: cached.token,
expiresAt: cached.expiresAt,
source: `cache:${cachePath}`,
baseUrl: deriveCopilotApiBaseUrlFromToken(cached.token) ?? DEFAULT_COPILOT_API_BASE_URL,
};
}
}
const fetchImpl = params.fetchImpl ?? fetch;
const res = await fetchImpl(COPILOT_TOKEN_URL, {
method: "GET",
headers: {
Accept: "application/json",
Authorization: `Bearer ${params.githubToken}`,
...buildCopilotIdeHeaders({ includeApiVersion: true }),
},
});
if (!res.ok) {
throw new Error(`Copilot token exchange failed: HTTP ${res.status}`);
}
const json = parseCopilotTokenResponse(await res.json());
const payload: CachedCopilotToken = {
token: json.token,
expiresAt: json.expiresAt,
updatedAt: Date.now(),
};
saveJsonFileFn(cachePath, payload);
return {
token: payload.token,
expiresAt: payload.expiresAt,
source: `fetched:${COPILOT_TOKEN_URL}`,
baseUrl: deriveCopilotApiBaseUrlFromToken(payload.token) ?? DEFAULT_COPILOT_API_BASE_URL,
};
}
export function isProviderApiKeyConfigured(params: {
provider: string;
agentDir?: string;

View File

@@ -41,3 +41,215 @@ export function extractToolPayload(result: ToolPayloadCarrier | null | undefined
return text;
}
}
export type PlainTextToolCallBlock = {
arguments: Record<string, unknown>;
end: number;
name: string;
raw: string;
start: number;
};
export type PlainTextToolCallParseOptions = {
allowedToolNames?: Iterable<string>;
maxPayloadBytes?: number;
};
const DEFAULT_MAX_PLAIN_TEXT_TOOL_PAYLOAD_BYTES = 256_000;
const END_TOOL_REQUEST = "[END_TOOL_REQUEST]";
function isToolNameChar(char: string | undefined): boolean {
return Boolean(char && /[A-Za-z0-9_-]/.test(char));
}
function skipHorizontalWhitespace(text: string, start: number): number {
let index = start;
while (index < text.length && (text[index] === " " || text[index] === "\t")) {
index += 1;
}
return index;
}
function skipWhitespace(text: string, start: number): number {
let index = start;
while (index < text.length && /\s/.test(text[index] ?? "")) {
index += 1;
}
return index;
}
function consumeLineBreak(text: string, start: number): number | null {
if (text[start] === "\r") {
return text[start + 1] === "\n" ? start + 2 : start + 1;
}
if (text[start] === "\n") {
return start + 1;
}
return null;
}
function parseOpening(text: string, start: number): { end: number; name: string } | null {
if (text[start] !== "[") {
return null;
}
let cursor = start + 1;
const nameStart = cursor;
while (isToolNameChar(text[cursor])) {
cursor += 1;
}
if (cursor === nameStart || text[cursor] !== "]") {
return null;
}
const name = text.slice(nameStart, cursor);
cursor += 1;
cursor = skipHorizontalWhitespace(text, cursor);
const afterLineBreak = consumeLineBreak(text, cursor);
if (afterLineBreak === null) {
return null;
}
return { end: afterLineBreak, name };
}
function consumeJsonObject(
text: string,
start: number,
maxPayloadBytes: number,
): { end: number; value: Record<string, unknown> } | null {
const cursor = skipWhitespace(text, start);
if (text[cursor] !== "{") {
return null;
}
let depth = 0;
let inString = false;
let escaped = false;
for (let index = cursor; index < text.length; index += 1) {
const char = text[index];
if (index + 1 - cursor > maxPayloadBytes) {
return null;
}
if (inString) {
if (escaped) {
escaped = false;
} else if (char === "\\") {
escaped = true;
} else if (char === '"') {
inString = false;
}
continue;
}
if (char === '"') {
inString = true;
continue;
}
if (char === "{") {
depth += 1;
} else if (char === "}") {
depth -= 1;
if (depth === 0) {
const rawJson = text.slice(cursor, index + 1);
try {
const parsed = JSON.parse(rawJson) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return null;
}
return { end: index + 1, value: parsed as Record<string, unknown> };
} catch {
return null;
}
}
}
}
return null;
}
function parseClosing(text: string, start: number, name: string): number | null {
const cursor = skipWhitespace(text, start);
if (text.startsWith(END_TOOL_REQUEST, cursor)) {
return cursor + END_TOOL_REQUEST.length;
}
const namedClosing = `[/${name}]`;
if (text.startsWith(namedClosing, cursor)) {
return cursor + namedClosing.length;
}
return null;
}
function parsePlainTextToolCallBlockAt(
text: string,
start: number,
options?: PlainTextToolCallParseOptions,
): PlainTextToolCallBlock | null {
const opening = parseOpening(text, start);
if (!opening) {
return null;
}
const allowedToolNames = options?.allowedToolNames
? new Set(options.allowedToolNames)
: undefined;
if (allowedToolNames && !allowedToolNames.has(opening.name)) {
return null;
}
const payload = consumeJsonObject(
text,
opening.end,
options?.maxPayloadBytes ?? DEFAULT_MAX_PLAIN_TEXT_TOOL_PAYLOAD_BYTES,
);
if (!payload) {
return null;
}
const end = parseClosing(text, payload.end, opening.name);
if (end === null) {
return null;
}
return {
arguments: payload.value,
end,
name: opening.name,
raw: text.slice(start, end),
start,
};
}
export function parseStandalonePlainTextToolCallBlocks(
text: string,
options?: PlainTextToolCallParseOptions,
): PlainTextToolCallBlock[] | null {
const blocks: PlainTextToolCallBlock[] = [];
let cursor = skipWhitespace(text, 0);
while (cursor < text.length) {
const block = parsePlainTextToolCallBlockAt(text, cursor, options);
if (!block) {
return null;
}
blocks.push(block);
cursor = skipWhitespace(text, block.end);
}
return blocks.length > 0 ? blocks : null;
}
export function stripPlainTextToolCallBlocks(text: string): string {
if (!text || !/\[[A-Za-z0-9_-]+\]/.test(text)) {
return text;
}
let result = "";
let cursor = 0;
let index = 0;
while (index < text.length) {
const lineStart = index === 0 || text[index - 1] === "\n";
if (!lineStart) {
index += 1;
continue;
}
const blockStart = skipHorizontalWhitespace(text, index);
const block = parsePlainTextToolCallBlockAt(text, blockStart);
if (!block) {
index += 1;
continue;
}
result += text.slice(cursor, index);
cursor = block.end;
index = block.end;
}
result += text.slice(cursor);
return result;
}