openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
290 lines (289 loc) • 9.94 kB
JavaScript
//#region packages/tool-call-repair/src/grammar.ts
/** Legacy marker some models emit after a serialized JSON tool request. */
const END_TOOL_REQUEST = "[END_TOOL_REQUEST]";
/** Harmony stream marker that introduces the target channel before a tool call. */
const HARMONY_CHANNEL_MARKER = "<|channel|>";
/** Harmony stream marker that may separate the header from the JSON payload. */
const HARMONY_MESSAGE_MARKER = "<|message|>";
/** Harmony stream marker that may close a serialized tool-call payload. */
const HARMONY_CALL_MARKER = "<|call|>";
/** Tool names in bracket/plain-text repairs intentionally match provider-safe ids only. */
function isPlainTextToolNameChar(char) {
return Boolean(char && /[A-Za-z0-9_-]/.test(char));
}
/** XML-ish function tags allow namespace punctuation used by some model families. */
function isXmlishNameChar(char) {
return Boolean(char && /[A-Za-z0-9_.:-]/.test(char));
}
/** Skips spaces and tabs only, preserving line boundaries for grammar decisions. */
function skipHorizontalWhitespace(text, start) {
let index = start;
while (index < text.length && (text[index] === " " || text[index] === " ")) index += 1;
return index;
}
/** Skips indentation whitespace without crossing the current line boundary. */
function skipLineIndentation(text, start) {
let index = start;
while (index < text.length && /[^\S\r\n]/u.test(text[index] ?? "")) index += 1;
return index;
}
/** Skips all JavaScript whitespace when line structure is no longer meaningful. */
function skipWhitespace(text, start) {
let index = start;
while (index < text.length && /\s/.test(text[index] ?? "")) index += 1;
return index;
}
/** Consumes either Unix or Windows line endings and returns the first offset after them. */
function consumeLineBreak(text, start) {
if (text[start] === "\r") return text[start + 1] === "\n" ? start + 2 : start + 1;
if (text[start] === "\n") return start + 1;
return null;
}
function consumeStructuralLineBreakAfterHorizontalWhitespace(text, start, options) {
const right = skipHorizontalWhitespace(text, start);
const actual = consumeLineBreak(text, right);
if (actual !== null) return actual;
for (let offset = start; offset <= right; offset += 1) if (options?.lineBreakOffsets.has(offset)) {
options.usedLineBreakOffsets?.add(offset);
return offset;
}
return null;
}
const utf8Encoder = new TextEncoder();
/** Returns the encoded byte length when a source span stays within its serialized limit. */
function utf8ByteLengthWithinLimit(text, start, end, maxBytes) {
if (end - start > maxBytes) return null;
const byteLength = utf8Encoder.encode(text.slice(start, end)).byteLength;
return byteLength <= maxBytes ? byteLength : null;
}
const FUNCTION_OPEN = "<function=";
const FUNCTION_CLOSE = "</function>";
const PARAMETER_OPEN = "<parameter=";
const PARAMETER_CLOSE = "</parameter>";
function startsWithAsciiMarkerIgnoreCase(text, cursor, marker) {
return text.slice(cursor, cursor + marker.length).toLowerCase() === marker;
}
function isAsciiMarkerPrefixIgnoreCase(text, cursor, marker) {
const rest = text.slice(cursor, cursor + marker.length).toLowerCase();
return rest.length < marker.length && marker.startsWith(rest);
}
function indexOfAsciiMarkerIgnoreCase(text, marker, start) {
for (let cursor = text.indexOf("<", start); cursor !== -1; cursor = text.indexOf("<", cursor + 1)) if (startsWithAsciiMarkerIgnoreCase(text, cursor, marker)) return cursor;
return -1;
}
function scanXmlishNameEnd(text, start, isNameChar = isXmlishNameChar) {
let end = start;
while (isNameChar(text[end]) && end - start < 121) end += 1;
return end;
}
/** Uncapped structural scan shared by parsing, stripping, and stream buffering. */
function scanXmlishToolCall(text, start = 0, structuralLineBreaks) {
let cursor = start;
let syntax;
let name;
if (text[cursor] === "<") {
if (!startsWithAsciiMarkerIgnoreCase(text, cursor, FUNCTION_OPEN) && !isAsciiMarkerPrefixIgnoreCase(text, cursor, FUNCTION_OPEN)) return {
kind: "invalid",
at: start
};
if (text.length - cursor < 10) return { kind: "prefix" };
cursor += 10;
const nameStart = cursor;
cursor = scanXmlishNameEnd(text, nameStart);
name = {
start: nameStart,
end: cursor
};
syntax = "function";
if (cursor - nameStart > 120) return {
kind: "invalid",
at: cursor
};
if (cursor === text.length) return {
kind: "prefix",
candidate: {
syntax,
name,
nameComplete: false,
parameters: []
}
};
if (cursor === nameStart || text[cursor] !== ">") return {
kind: "invalid",
at: cursor
};
cursor += 1;
} else if (text[cursor] === "[") {
cursor += 1;
const firstNameStart = cursor;
cursor = scanXmlishNameEnd(text, firstNameStart, isPlainTextToolNameChar);
if (cursor - firstNameStart > 120) return {
kind: "invalid",
at: cursor
};
const firstName = text.slice(firstNameStart, cursor);
if (cursor === text.length && "tool".startsWith(firstName)) return { kind: "prefix" };
syntax = "named-bracket";
name = {
start: firstNameStart,
end: cursor
};
if (text[cursor] === ":" && firstName === "tool") {
syntax = "tool-bracket";
cursor += 1;
const nameStart = cursor;
cursor = scanXmlishNameEnd(text, nameStart, isPlainTextToolNameChar);
name = {
start: nameStart,
end: cursor
};
if (cursor - nameStart > 120) return {
kind: "invalid",
at: cursor
};
}
if (cursor === text.length) return {
kind: "prefix",
candidate: {
syntax,
name,
nameComplete: false,
parameters: []
}
};
if (name.start === name.end || text[cursor] !== "]") return {
kind: "invalid",
at: cursor
};
cursor += 1;
if (syntax === "named-bracket") {
if (cursor === text.length) return {
kind: "prefix",
candidate: {
syntax,
name,
nameComplete: true,
parameters: []
}
};
const afterLineBreak = consumeStructuralLineBreakAfterHorizontalWhitespace(text, cursor, structuralLineBreaks);
if (afterLineBreak === null) return {
kind: "invalid",
at: cursor
};
cursor = afterLineBreak;
}
} else return {
kind: "invalid",
at: start
};
const bodyStart = cursor;
const parameters = [];
const candidate = (payloadEnd, activeParameterOpenEnd) => ({
syntax,
name,
nameComplete: true,
parameters,
payload: {
start: bodyStart,
end: payloadEnd
},
...activeParameterOpenEnd === void 0 ? {} : { activeParameterOpenEnd }
});
let lastParameterEnd;
const prefix = (payloadEnd, activeParameterOpenEnd) => ({
kind: "prefix",
candidate: candidate(payloadEnd, activeParameterOpenEnd),
completeEnd: syntax === "tool-bracket" ? lastParameterEnd : void 0
});
const complete = (payloadEnd, end = payloadEnd) => ({
kind: "complete",
...candidate(payloadEnd),
end
});
while (true) {
const markerStart = skipWhitespace(text, cursor);
if (markerStart === text.length) return syntax === "tool-bracket" && lastParameterEnd !== void 0 ? complete(lastParameterEnd) : {
kind: "prefix",
candidate: candidate(text.length)
};
if (startsWithAsciiMarkerIgnoreCase(text, markerStart, FUNCTION_CLOSE)) return syntax !== "function" && parameters.length === 0 ? {
kind: "invalid",
at: markerStart,
candidate: candidate(markerStart)
} : complete(markerStart, markerStart + 11);
if (isAsciiMarkerPrefixIgnoreCase(text, markerStart, FUNCTION_CLOSE)) return prefix(markerStart);
if (startsWithAsciiMarkerIgnoreCase(text, markerStart, PARAMETER_OPEN)) {
const nameStart = markerStart + 11;
const nameEnd = scanXmlishNameEnd(text, nameStart);
if (nameEnd - nameStart > 120) return {
kind: "invalid",
at: markerStart,
candidate: candidate(markerStart)
};
if (nameEnd === text.length) return prefix(markerStart);
if (nameEnd === nameStart || text[nameEnd] !== ">") return {
kind: "invalid",
at: markerStart,
candidate: candidate(markerStart)
};
const valueStart = nameEnd + 1;
const closeStart = indexOfAsciiMarkerIgnoreCase(text, PARAMETER_CLOSE, valueStart);
if (closeStart === -1) return prefix(text.length, valueStart);
const end = closeStart + 12;
parameters.push({
name: {
start: nameStart,
end: nameEnd
},
value: {
start: valueStart,
end: closeStart
}
});
cursor = end;
lastParameterEnd = end;
continue;
}
if (isAsciiMarkerPrefixIgnoreCase(text, markerStart, PARAMETER_OPEN)) return prefix(markerStart);
if (syntax === "tool-bracket" && lastParameterEnd !== void 0) return complete(lastParameterEnd);
return {
kind: "invalid",
at: markerStart,
candidate: candidate(markerStart)
};
}
}
/** Continue the same object scan across parser spans or bounded stream chunks. */
function scanJsonObject(text, start, state = {
depth: 0,
escaped: false,
inString: false
}) {
for (let index = start; index < text.length; index += 1) {
const char = text[index];
if (state.inString) {
if (state.escaped) state.escaped = false;
else if (char === "\\") state.escaped = true;
else if (char === "\"") state.inString = false;
continue;
}
if (char === "\"") state.inString = true;
else if (char === "{") state.depth += 1;
else if (char === "}") {
state.depth -= 1;
if (state.depth === 0) return {
kind: "complete",
end: index + 1,
state
};
}
}
return {
kind: "prefix",
end: text.length,
state
};
}
//#endregion
export { utf8ByteLengthWithinLimit as _, consumeLineBreak as a, isAsciiMarkerPrefixIgnoreCase as c, scanJsonObject as d, scanXmlishToolCall as f, startsWithAsciiMarkerIgnoreCase as g, skipWhitespace as h, HARMONY_MESSAGE_MARKER as i, isPlainTextToolNameChar as l, skipLineIndentation as m, HARMONY_CALL_MARKER as n, consumeStructuralLineBreakAfterHorizontalWhitespace as o, skipHorizontalWhitespace as p, HARMONY_CHANNEL_MARKER as r, indexOfAsciiMarkerIgnoreCase as s, END_TOOL_REQUEST as t, isXmlishNameChar as u };