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:
Peter Steinberger
2026-07-05 22:21:54 +01:00
committed by GitHub
parent 8c19dbfb62
commit a4b032e5d7
24 changed files with 555 additions and 71 deletions
@@ -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.");
});
+181 -42
View File
@@ -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);
}
/**