mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
fix: harden subprocess, maintenance, and output paths (#100440)
* fix(agents): contain exec output stream failures Co-authored-by: 陈宪彪0668000387 <chen.xianbiao@xydigit.com> * fix(tui): contain local shell stream failures Co-authored-by: 陈宪彪0668000387 <chen.xianbiao@xydigit.com> * fix(process): contain command output stream failures Co-authored-by: 陈宪彪0668000387 <chen.xianbiao@xydigit.com> * fix(skills): isolate remote bin refresh failures Co-authored-by: 陈宪彪0668000387 <chen.xianbiao@xydigit.com> * fix(agents): contain background subagent sweep failures Co-authored-by: 陈宪彪0668000387 <chen.xianbiao@xydigit.com> * fix(skills): report directory scan failures Co-authored-by: wendy-chsy <wan.wenyan@xydigit.com> * fix(agents): classify plugin approval gateway failures Co-authored-by: 唐梓夷0668001293 <tang.ziyi@xydigit.com> * fix(exec): preserve sanitized control-byte evidence Co-authored-by: Lavya Tandel <lavya@loom.local> * fix(shared): unwrap standalone parameter tags Co-authored-by: nankingjing <1079826437@qq.com> * fix(android): fail shared capture on audio read errors Co-authored-by: NianJiuZst <3235467914@qq.com> * docs(changelog): record small bugfix sweep * fix(output): preserve sanitizer boundary semantics * test: align sanitizer and sweeper regressions * fix(terminal): preserve text after lone C1 controls --------- Co-authored-by: 陈宪彪0668000387 <chen.xianbiao@xydigit.com> Co-authored-by: wendy-chsy <wan.wenyan@xydigit.com> Co-authored-by: 唐梓夷0668001293 <tang.ziyi@xydigit.com> Co-authored-by: Lavya Tandel <lavya@loom.local> Co-authored-by: nankingjing <1079826437@qq.com> Co-authored-by: NianJiuZst <3235467914@qq.com>
This commit is contained in:
committed by
GitHub
parent
8c19dbfb62
commit
a4b032e5d7
@@ -389,6 +389,51 @@ describe("stripAssistantInternalScaffolding", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("unwraps standalone parameter tags while preserving their content (#98557)", () => {
|
||||
expectVisibleText(
|
||||
'Results: <parameter name="assumptions">some content</parameter> after.',
|
||||
"Results: some content after.",
|
||||
);
|
||||
expectVisibleText(
|
||||
['<parameter name="assumptions">', "line 1", "line 2", "</parameter>"].join("\n"),
|
||||
"line 1\nline 2",
|
||||
);
|
||||
expectVisibleText('<parameter name="data">{"key":"value"}</parameter>', '{"key":"value"}');
|
||||
expectVisibleText('<parameter name="items">[1,2]</parameter>', "[1,2]");
|
||||
expectVisibleText(
|
||||
'Results:<parameter name="x">\nline\n</parameter>after',
|
||||
"Results:\nline\nafter",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps truncated tool-call parameters fail-closed", () => {
|
||||
expectVisibleText('<tool_call><parameter name="token">secret</parameter>', "");
|
||||
});
|
||||
|
||||
it("preserves parameter tags in code and literal function examples", () => {
|
||||
expectVisibleText(
|
||||
'Use `<parameter name="path">/tmp</parameter>`.',
|
||||
'Use `<parameter name="path">/tmp</parameter>`.',
|
||||
);
|
||||
expectVisibleText(
|
||||
'Use <function name="read"><parameter name="path">/tmp</parameter></function> in docs.',
|
||||
'Use <function name="read"><parameter name="path">/tmp</parameter></function> in docs.',
|
||||
);
|
||||
expectVisibleText(
|
||||
'<schema><parameter name="path">/tmp</parameter></schema>',
|
||||
'<schema><parameter name="path">/tmp</parameter></schema>',
|
||||
);
|
||||
expectVisibleText(
|
||||
'<schema><parameter name="path"/></schema>',
|
||||
'<schema><parameter name="path"/></schema>',
|
||||
);
|
||||
expectVisibleText('<br><parameter name="path">/tmp</parameter>', "<br>/tmp");
|
||||
expectVisibleText(
|
||||
'Use <function> declarations. <parameter name="path">/tmp</parameter>',
|
||||
"Use <function> declarations. /tmp",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves XML-style explanations after lone <tool_call> tags", () => {
|
||||
expectVisibleText("Use <tool_call><arg> literally.", "Use <tool_call><arg> literally.");
|
||||
});
|
||||
|
||||
@@ -90,6 +90,61 @@ interface ParsedToolCallTag {
|
||||
isTruncated: boolean;
|
||||
}
|
||||
|
||||
function parseXmlTagAt(text: string, start: number): ParsedToolCallTag | null {
|
||||
if (text[start] !== "<") {
|
||||
return null;
|
||||
}
|
||||
|
||||
let cursor = start + 1;
|
||||
while (cursor < text.length && /\s/.test(text[cursor])) {
|
||||
cursor += 1;
|
||||
}
|
||||
|
||||
let isClose = false;
|
||||
if (text[cursor] === "/") {
|
||||
isClose = true;
|
||||
cursor += 1;
|
||||
while (cursor < text.length && /\s/.test(text[cursor])) {
|
||||
cursor += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const nameStart = cursor;
|
||||
if (!/[A-Za-z_:]/.test(text[cursor] ?? "")) {
|
||||
return null;
|
||||
}
|
||||
cursor += 1;
|
||||
while (cursor < text.length && /[A-Za-z0-9_.:-]/.test(text[cursor])) {
|
||||
cursor += 1;
|
||||
}
|
||||
|
||||
const tagName = normalizeLowercaseStringOrEmpty(text.slice(nameStart, cursor));
|
||||
if (!isToolCallBoundary(text[cursor])) {
|
||||
return null;
|
||||
}
|
||||
const contentStart = cursor;
|
||||
const closeIndex = findTagCloseIndex(text, cursor);
|
||||
if (closeIndex === -1) {
|
||||
return {
|
||||
contentStart,
|
||||
end: text.length,
|
||||
isClose,
|
||||
isSelfClosing: false,
|
||||
tagName,
|
||||
isTruncated: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
contentStart,
|
||||
end: closeIndex + 1,
|
||||
isClose,
|
||||
isSelfClosing: !isClose && /\/\s*$/.test(text.slice(cursor, closeIndex)),
|
||||
tagName,
|
||||
isTruncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
function isToolCallBoundary(char: string | undefined): boolean {
|
||||
return !char || /\s/.test(char) || char === "/" || char === ">";
|
||||
}
|
||||
@@ -279,64 +334,148 @@ function findAdjacentOpeningToolCallTag(
|
||||
}
|
||||
|
||||
function parseToolCallTagAt(text: string, start: number): ParsedToolCallTag | null {
|
||||
if (text[start] !== "<") {
|
||||
return null;
|
||||
}
|
||||
const tag = parseXmlTagAt(text, start);
|
||||
return tag && TOOL_CALL_TAG_NAMES.has(tag.tagName) ? tag : null;
|
||||
}
|
||||
|
||||
let cursor = start + 1;
|
||||
function hasMatchingXmlCloseTag(text: string, start: number, tagName: string): boolean {
|
||||
let depth = 1;
|
||||
for (let idx = start; idx < text.length; idx += 1) {
|
||||
if (text[idx] !== "<") {
|
||||
continue;
|
||||
}
|
||||
const tag = parseXmlTagAt(text, idx);
|
||||
if (!tag || tag.tagName !== tagName || tag.isTruncated) {
|
||||
continue;
|
||||
}
|
||||
if (tag.isClose) {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
return true;
|
||||
}
|
||||
} else if (!tag.isSelfClosing) {
|
||||
depth += 1;
|
||||
}
|
||||
idx = Math.max(idx, tag.end - 1);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isDanglingFunctionParameterParent(text: string, tag: ParsedToolCallTag): boolean {
|
||||
if (tag.tagName !== "function" || !/\bname\s*=/.test(text.slice(tag.contentStart, tag.end))) {
|
||||
return false;
|
||||
}
|
||||
let cursor = tag.end;
|
||||
while (cursor < text.length && /\s/.test(text[cursor])) {
|
||||
cursor += 1;
|
||||
}
|
||||
const nextTag = parseXmlTagAt(text, cursor);
|
||||
return nextTag?.tagName === "parameter" && !nextTag.isClose;
|
||||
}
|
||||
|
||||
let isClose = false;
|
||||
if (text[cursor] === "/") {
|
||||
isClose = true;
|
||||
function consumeImmediateLineBreak(text: string, start: number): number | null {
|
||||
if (text[start] === "\r" && text[start + 1] === "\n") {
|
||||
return start + 2;
|
||||
}
|
||||
return text[start] === "\n" || text[start] === "\r" ? start + 1 : null;
|
||||
}
|
||||
|
||||
function trimImmediateLineBreakBefore(text: string, start: number, end: number): number {
|
||||
if (end > start && text[end - 1] === "\n") {
|
||||
return end - (end - 2 >= start && text[end - 2] === "\r" ? 2 : 1);
|
||||
}
|
||||
return end > start && text[end - 1] === "\r" ? end - 1 : end;
|
||||
}
|
||||
|
||||
function isLineStartAt(text: string, start: number): boolean {
|
||||
let cursor = start - 1;
|
||||
while (cursor >= 0 && (text[cursor] === " " || text[cursor] === "\t")) {
|
||||
cursor -= 1;
|
||||
}
|
||||
return cursor < 0 || text[cursor] === "\n" || text[cursor] === "\r";
|
||||
}
|
||||
|
||||
function isLineEndAfter(text: string, end: number): boolean {
|
||||
let cursor = end;
|
||||
while (cursor < text.length && (text[cursor] === " " || text[cursor] === "\t")) {
|
||||
cursor += 1;
|
||||
while (cursor < text.length && /\s/.test(text[cursor])) {
|
||||
cursor += 1;
|
||||
}
|
||||
return cursor >= text.length || text[cursor] === "\n" || text[cursor] === "\r";
|
||||
}
|
||||
|
||||
function unwrapStandaloneParameterTags(text: string): string {
|
||||
if (!/<\s*\/?\s*parameter\b/i.test(text)) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const codeRegions = findCodeRegions(text);
|
||||
const openTags: Array<{ name: string; unwrap: boolean; trimBoundaryLineBreaks: boolean }> = [];
|
||||
let result = "";
|
||||
let lastIndex = 0;
|
||||
|
||||
for (let idx = 0; idx < text.length; idx += 1) {
|
||||
if (text[idx] !== "<" || isInsideCode(idx, codeRegions)) {
|
||||
continue;
|
||||
}
|
||||
const tag = parseXmlTagAt(text, idx);
|
||||
if (!tag || tag.isTruncated) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tag.isClose) {
|
||||
const openIndex = openTags.findLastIndex((entry) => entry.name === tag.tagName);
|
||||
if (openIndex !== -1) {
|
||||
const opening = openTags[openIndex];
|
||||
if (opening.unwrap) {
|
||||
const contentEnd =
|
||||
opening.trimBoundaryLineBreaks &&
|
||||
isLineStartAt(text, idx) &&
|
||||
isLineEndAfter(text, tag.end)
|
||||
? trimImmediateLineBreakBefore(text, lastIndex, idx)
|
||||
: idx;
|
||||
result += text.slice(lastIndex, contentEnd);
|
||||
lastIndex = tag.end;
|
||||
}
|
||||
openTags.splice(openIndex);
|
||||
}
|
||||
} else if (tag.isSelfClosing) {
|
||||
if (tag.tagName === "parameter" && openTags.length === 0) {
|
||||
result += text.slice(lastIndex, idx);
|
||||
lastIndex = tag.end;
|
||||
}
|
||||
} else if (
|
||||
hasMatchingXmlCloseTag(text, tag.end, tag.tagName) ||
|
||||
isDanglingFunctionParameterParent(text, tag)
|
||||
) {
|
||||
const unwrap = tag.tagName === "parameter" && openTags.length === 0;
|
||||
let trimBoundaryLineBreaks = false;
|
||||
if (unwrap) {
|
||||
result += text.slice(lastIndex, idx);
|
||||
lastIndex = tag.end;
|
||||
const contentStart = isLineStartAt(text, idx)
|
||||
? consumeImmediateLineBreak(text, lastIndex)
|
||||
: null;
|
||||
if (contentStart !== null) {
|
||||
lastIndex = contentStart;
|
||||
trimBoundaryLineBreaks = true;
|
||||
}
|
||||
}
|
||||
openTags.push({ name: tag.tagName, unwrap, trimBoundaryLineBreaks });
|
||||
}
|
||||
idx = Math.max(idx, tag.end - 1);
|
||||
}
|
||||
|
||||
const nameStart = cursor;
|
||||
while (cursor < text.length && /[A-Za-z_:]/.test(text[cursor])) {
|
||||
cursor += 1;
|
||||
}
|
||||
|
||||
const tagName = normalizeLowercaseStringOrEmpty(text.slice(nameStart, cursor));
|
||||
if (!TOOL_CALL_TAG_NAMES.has(tagName) || !isToolCallBoundary(text[cursor])) {
|
||||
return null;
|
||||
}
|
||||
const contentStart = cursor;
|
||||
|
||||
const closeIndex = findTagCloseIndex(text, cursor);
|
||||
if (closeIndex === -1) {
|
||||
return {
|
||||
contentStart,
|
||||
end: text.length,
|
||||
isClose,
|
||||
isSelfClosing: false,
|
||||
tagName,
|
||||
isTruncated: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
contentStart,
|
||||
end: closeIndex + 1,
|
||||
isClose,
|
||||
isSelfClosing: !isClose && /\/\s*$/.test(text.slice(cursor, closeIndex)),
|
||||
tagName,
|
||||
isTruncated: false,
|
||||
};
|
||||
return result + text.slice(lastIndex);
|
||||
}
|
||||
|
||||
export function stripToolCallXmlTags(
|
||||
text: string,
|
||||
input: string,
|
||||
options: {
|
||||
stripFunctionCallsXmlPayloads?: boolean;
|
||||
stripFunctionResponseAfterPluralToolCalls?: boolean;
|
||||
} = {},
|
||||
): string {
|
||||
const text = input;
|
||||
if (!text || !TOOL_CALL_QUICK_RE.test(text)) {
|
||||
return text;
|
||||
}
|
||||
@@ -484,7 +623,7 @@ export function stripToolCallXmlTags(
|
||||
result += text.slice(toolCallBlockStart);
|
||||
}
|
||||
|
||||
return result;
|
||||
return unwrapStandaloneParameterTags(result);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user