Support nested fenced blocks in stream fence tracking

This commit is contained in:
CJACK.
2026-03-22 15:12:55 +08:00
parent 2caabd8ce6
commit b108a7915a
11 changed files with 283 additions and 36 deletions

View File

@@ -7,6 +7,8 @@ import (
var toolCallPattern = regexp.MustCompile(`\{\s*["']tool_calls["']\s*:\s*\[(.*?)\]\s*\}`)
var fencedJSONPattern = regexp.MustCompile("(?s)```(?:json)?\\s*(.*?)\\s*```")
var fencedCodeBlockPattern = regexp.MustCompile("(?s)```[\\s\\S]*?```")
var markupToolSyntaxPattern = regexp.MustCompile(`(?i)<(?:(?:[a-z0-9_:-]+:)?(?:tool_call|function_call|invoke)\b|(?:[a-z0-9_:-]+:)?function_calls\b|(?:[a-z0-9_:-]+:)?tool_use\b)`)
func buildToolCallCandidates(text string) []string {
trimmed := strings.TrimSpace(text)
@@ -173,3 +175,22 @@ func looksLikeToolExampleContext(text string) bool {
}
return strings.Contains(t, "```")
}
func shouldSkipToolCallParsingForCodeFenceExample(text string) bool {
if !looksLikeToolCallSyntax(text) || looksLikeMarkupToolSyntax(text) {
return false
}
stripped := strings.TrimSpace(stripFencedCodeBlocks(text))
return !looksLikeToolCallSyntax(stripped)
}
func looksLikeMarkupToolSyntax(text string) bool {
return markupToolSyntaxPattern.MatchString(text)
}
func stripFencedCodeBlocks(text string) string {
if text == "" {
return ""
}
return fencedCodeBlockPattern.ReplaceAllString(text, " ")
}

View File

@@ -26,6 +26,9 @@ func ParseToolCallsDetailed(text string, availableToolNames []string) ToolCallPa
return result
}
result.SawToolCallSyntax = looksLikeToolCallSyntax(text)
if shouldSkipToolCallParsingForCodeFenceExample(text) {
return result
}
candidates := buildToolCallCandidates(text)
var parsed []ParsedToolCall
@@ -74,6 +77,9 @@ func ParseStandaloneToolCallsDetailed(text string, availableToolNames []string)
return result
}
result.SawToolCallSyntax = looksLikeToolCallSyntax(trimmed)
if shouldSkipToolCallParsingForCodeFenceExample(trimmed) {
return result
}
candidates := buildToolCallCandidates(trimmed)
var parsed []ParsedToolCall
for _, candidate := range candidates {
@@ -183,6 +189,9 @@ func parseToolCallsPayload(payload string) []ParsedToolCall {
switch v := decoded.(type) {
case map[string]any:
if tc, ok := v["tool_calls"]; ok {
if isLikelyChatMessageEnvelope(v) {
return nil
}
return parseToolCallList(tc)
}
if parsed, ok := parseToolCallItem(v); ok {
@@ -194,6 +203,28 @@ func parseToolCallsPayload(payload string) []ParsedToolCall {
return nil
}
func isLikelyChatMessageEnvelope(v map[string]any) bool {
if v == nil {
return false
}
if _, ok := v["tool_calls"]; !ok {
return false
}
if role, ok := v["role"].(string); ok {
switch strings.ToLower(strings.TrimSpace(role)) {
case "assistant", "tool", "user", "system":
return true
}
}
if _, ok := v["tool_call_id"]; ok {
return true
}
if _, ok := v["content"]; ok {
return true
}
return false
}
func looksLikeToolCallSyntax(text string) bool {
lower := strings.ToLower(text)
return strings.Contains(lower, "tool_calls") ||

View File

@@ -19,11 +19,11 @@ func TestParseToolCalls(t *testing.T) {
}
}
func TestParseToolCallsFromFencedJSON(t *testing.T) {
func TestParseToolCallsIgnoresFencedJSON(t *testing.T) {
text := "I will call tools now\n```json\n{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"news\"}}]}\n```"
calls := ParseToolCalls(text, []string{"search"})
if len(calls) != 1 {
t.Fatalf("expected fenced tool_call payload to be parsed, got %#v", calls)
if len(calls) != 0 {
t.Fatalf("expected fenced tool_call payload to be ignored, got %#v", calls)
}
}
@@ -112,10 +112,17 @@ func TestParseStandaloneToolCallsSupportsMixedProsePayload(t *testing.T) {
}
}
func TestParseStandaloneToolCallsParsesFencedCodeBlock(t *testing.T) {
func TestParseStandaloneToolCallsIgnoresFencedCodeBlock(t *testing.T) {
fenced := "```json\n{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"go\"}}]}\n```"
if calls := ParseStandaloneToolCalls(fenced, []string{"search"}); len(calls) != 1 {
t.Fatalf("expected fenced tool_call payload to be parsed, got %#v", calls)
if calls := ParseStandaloneToolCalls(fenced, []string{"search"}); len(calls) != 0 {
t.Fatalf("expected fenced tool_call payload to be ignored, got %#v", calls)
}
}
func TestParseStandaloneToolCallsIgnoresChatTranscriptEnvelope(t *testing.T) {
transcript := `[{"role":"user","content":"请展示完整会话"},{"role":"assistant","content":null,"tool_calls":[{"function":{"name":"search","arguments":"{\"q\":\"go\"}"}}]}]`
if calls := ParseStandaloneToolCalls(transcript, []string{"search"}); len(calls) != 0 {
t.Fatalf("expected transcript envelope not to trigger tool call parse, got %#v", calls)
}
}

View File

@@ -409,8 +409,8 @@ func TestParseToolCallsWithFunctionWrapper(t *testing.T) {
func TestParseStandaloneToolCallsFencedCodeBlock(t *testing.T) {
fenced := "Here's an example:\n```json\n{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"go\"}}]}\n```\nDon't execute this."
calls := ParseStandaloneToolCalls(fenced, []string{"search"})
if len(calls) != 1 {
t.Fatalf("expected fenced code block to be parsed, got %d calls", len(calls))
if len(calls) != 0 {
t.Fatalf("expected fenced code block to be ignored, got %d calls", len(calls))
}
}