From b790545d82fb21ad98da369a69710f26c3ef6839 Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Mon, 30 Mar 2026 11:23:16 +0800 Subject: [PATCH 1/2] Fix dangling agent XML cleanup and escape tool call prompt XML --- .../adapter/openai/leaked_output_sanitize.go | 9 +++++++++ .../openai/leaked_output_sanitize_test.go | 16 ++++++++++++++++ internal/prompt/tool_calls.go | 17 +++++++++++++++-- internal/prompt/tool_calls_test.go | 13 +++++++++++++ 4 files changed, 53 insertions(+), 2 deletions(-) diff --git a/internal/adapter/openai/leaked_output_sanitize.go b/internal/adapter/openai/leaked_output_sanitize.go index 2e7dcc1..481492d 100644 --- a/internal/adapter/openai/leaked_output_sanitize.go +++ b/internal/adapter/openai/leaked_output_sanitize.go @@ -23,6 +23,7 @@ var leakedAgentXMLBlockPatterns = []*regexp.Regexp{ regexp.MustCompile(`(?is)]*>(.*?)`), } +var leakedAgentWrapperTagPattern = regexp.MustCompile(`(?is)]*>`) var leakedAgentResultTagPattern = regexp.MustCompile(`(?is)`) func sanitizeLeakedOutput(text string) string { @@ -50,5 +51,13 @@ func sanitizeLeakedAgentXMLBlocks(text string) string { return leakedAgentResultTagPattern.ReplaceAllString(submatches[1], "") }) } + // Fallback for truncated output streams: strip any dangling wrapper tags + // that were not part of a complete block replacement. If we detect leaked + // wrapper tags, also strip sibling tags to avoid exposing agent + // markup in user-visible text. + if leakedAgentWrapperTagPattern.MatchString(out) { + out = leakedAgentWrapperTagPattern.ReplaceAllString(out, "") + out = leakedAgentResultTagPattern.ReplaceAllString(out, "") + } return out } diff --git a/internal/adapter/openai/leaked_output_sanitize_test.go b/internal/adapter/openai/leaked_output_sanitize_test.go index 90ce9d1..1898289 100644 --- a/internal/adapter/openai/leaked_output_sanitize_test.go +++ b/internal/adapter/openai/leaked_output_sanitize_test.go @@ -41,3 +41,19 @@ func TestSanitizeLeakedOutputPreservesStandaloneResultTags(t *testing.T) { t.Fatalf("unexpected sanitize result for standalone result tag: %q", got) } } + +func TestSanitizeLeakedOutputRemovesDanglingAgentXMLOpeningTags(t *testing.T) { + raw := "Done.Some final answer" + got := sanitizeLeakedOutput(raw) + if got != "Done.Some final answer" { + t.Fatalf("unexpected sanitize result for dangling opening tags: %q", got) + } +} + +func TestSanitizeLeakedOutputRemovesDanglingAgentXMLClosingTags(t *testing.T) { + raw := "Done.Some final answer" + got := sanitizeLeakedOutput(raw) + if got != "Done.Some final answer" { + t.Fatalf("unexpected sanitize result for dangling closing tags: %q", got) + } +} diff --git a/internal/prompt/tool_calls.go b/internal/prompt/tool_calls.go index 718c48b..d8a2df9 100644 --- a/internal/prompt/tool_calls.go +++ b/internal/prompt/tool_calls.go @@ -5,6 +5,12 @@ import ( "strings" ) +var promptXMLTextEscaper = strings.NewReplacer( + "&", "&", + "<", "<", + ">", ">", +) + // FormatToolCallsForPrompt renders a tool_calls slice into the canonical // prompt-visible history block used across adapters. func FormatToolCallsForPrompt(raw any) string { @@ -82,8 +88,8 @@ func formatToolCallForPrompt(call map[string]any) string { } return " \n" + - " " + name + "\n" + - " " + StringifyToolCallArguments(argsRaw) + "\n" + + " " + escapeXMLText(name) + "\n" + + " " + escapeXMLText(StringifyToolCallArguments(argsRaw)) + "\n" + " " } @@ -122,3 +128,10 @@ func asString(v any) string { } return "" } + +func escapeXMLText(v string) string { + if v == "" { + return "" + } + return promptXMLTextEscaper.Replace(v) +} diff --git a/internal/prompt/tool_calls_test.go b/internal/prompt/tool_calls_test.go index 8ad4407..3eb2c1e 100644 --- a/internal/prompt/tool_calls_test.go +++ b/internal/prompt/tool_calls_test.go @@ -26,3 +26,16 @@ func TestFormatToolCallsForPromptXML(t *testing.T) { t.Fatalf("unexpected formatted tool call XML: %q", got) } } + +func TestFormatToolCallsForPromptEscapesXMLEntities(t *testing.T) { + got := FormatToolCallsForPrompt([]any{ + map[string]any{ + "name": "search<&>", + "arguments": `{"q":"a < b && c > d"}`, + }, + }) + want := "\n \n search<&>\n {\"q\":\"a < b && c > d\"}\n \n" + if got != want { + t.Fatalf("unexpected escaped tool call XML: %q", got) + } +} From 535d9298a7fc780aca40df60e6f56a1c9816a4b8 Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Mon, 30 Mar 2026 12:22:04 +0800 Subject: [PATCH 2/2] Scope dangling result-tag cleanup to leaked wrapper fragments --- internal/adapter/openai/leaked_output_sanitize.go | 13 ++++++++++--- .../adapter/openai/leaked_output_sanitize_test.go | 9 +++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/internal/adapter/openai/leaked_output_sanitize.go b/internal/adapter/openai/leaked_output_sanitize.go index 481492d..cb6e7c4 100644 --- a/internal/adapter/openai/leaked_output_sanitize.go +++ b/internal/adapter/openai/leaked_output_sanitize.go @@ -24,6 +24,8 @@ var leakedAgentXMLBlockPatterns = []*regexp.Regexp{ } var leakedAgentWrapperTagPattern = regexp.MustCompile(`(?is)]*>`) +var leakedAgentWrapperPlusResultOpenPattern = regexp.MustCompile(`(?is)<(?:attempt_completion|ask_followup_question|new_task)\b[^>]*>\s*`) +var leakedAgentResultPlusWrapperClosePattern = regexp.MustCompile(`(?is)\s*]*>`) var leakedAgentResultTagPattern = regexp.MustCompile(`(?is)`) func sanitizeLeakedOutput(text string) string { @@ -53,11 +55,16 @@ func sanitizeLeakedAgentXMLBlocks(text string) string { } // Fallback for truncated output streams: strip any dangling wrapper tags // that were not part of a complete block replacement. If we detect leaked - // wrapper tags, also strip sibling tags to avoid exposing agent - // markup in user-visible text. + // wrapper tags, strip only adjacent tags to avoid exposing agent + // markup without altering unrelated user-visible examples. if leakedAgentWrapperTagPattern.MatchString(out) { + out = leakedAgentWrapperPlusResultOpenPattern.ReplaceAllStringFunc(out, func(match string) string { + return leakedAgentResultTagPattern.ReplaceAllString(match, "") + }) + out = leakedAgentResultPlusWrapperClosePattern.ReplaceAllStringFunc(out, func(match string) string { + return leakedAgentResultTagPattern.ReplaceAllString(match, "") + }) out = leakedAgentWrapperTagPattern.ReplaceAllString(out, "") - out = leakedAgentResultTagPattern.ReplaceAllString(out, "") } return out } diff --git a/internal/adapter/openai/leaked_output_sanitize_test.go b/internal/adapter/openai/leaked_output_sanitize_test.go index 1898289..6548d39 100644 --- a/internal/adapter/openai/leaked_output_sanitize_test.go +++ b/internal/adapter/openai/leaked_output_sanitize_test.go @@ -57,3 +57,12 @@ func TestSanitizeLeakedOutputRemovesDanglingAgentXMLClosingTags(t *testing.T) { t.Fatalf("unexpected sanitize result for dangling closing tags: %q", got) } } + +func TestSanitizeLeakedOutputPreservesUnrelatedResultTagsWhenWrapperLeaks(t *testing.T) { + raw := "Done.Some final answer\nExample XML: value" + got := sanitizeLeakedOutput(raw) + want := "Done.Some final answer\nExample XML: value" + if got != want { + t.Fatalf("unexpected sanitize result for mixed leaked wrapper + xml example: %q", got) + } +}