Files
ds2api/internal/toolcall/toolcalls_parse.go
2026-05-10 18:02:54 +08:00

317 lines
7.3 KiB
Go

package toolcall
import (
"strings"
)
type ParsedToolCall struct {
Name string `json:"name"`
Input map[string]any `json:"input"`
}
type ToolCallParseResult struct {
Calls []ParsedToolCall
SawToolCallSyntax bool
RejectedByPolicy bool
RejectedToolNames []string
}
func ParseToolCalls(text string, availableToolNames []string) []ParsedToolCall {
return ParseToolCallsDetailed(text, availableToolNames).Calls
}
func ParseToolCallsDetailed(text string, availableToolNames []string) ToolCallParseResult {
return parseToolCallsDetailedXMLOnly(text)
}
func ParseStandaloneToolCalls(text string, availableToolNames []string) []ParsedToolCall {
return ParseStandaloneToolCallsDetailed(text, availableToolNames).Calls
}
func ParseStandaloneToolCallsDetailed(text string, availableToolNames []string) ToolCallParseResult {
return parseToolCallsDetailedXMLOnly(text)
}
func ParseAssistantToolCallsDetailed(text, thinking string, availableToolNames []string) ToolCallParseResult {
textParsed := ParseStandaloneToolCallsDetailed(text, availableToolNames)
if len(textParsed.Calls) > 0 {
return textParsed
}
if strings.TrimSpace(text) != "" {
return textParsed
}
thinkingParsed := ParseStandaloneToolCallsDetailed(thinking, availableToolNames)
if len(thinkingParsed.Calls) > 0 {
return thinkingParsed
}
return textParsed
}
func parseToolCallsDetailedXMLOnly(text string) ToolCallParseResult {
result := ToolCallParseResult{}
trimmed := strings.TrimSpace(text)
if trimmed == "" {
return result
}
trimmed = stripFencedCodeBlocks(trimmed)
trimmed = strings.TrimSpace(trimmed)
if trimmed == "" {
return result
}
normalized, ok := normalizeDSMLToolCallMarkup(trimmed)
if !ok {
return result
}
result.SawToolCallSyntax = looksLikeToolCallSyntax(normalized) || hasRepairableXMLToolCallsWrapper(normalized)
parsed := parseXMLToolCalls(normalized)
if len(parsed) == 0 && indexToolCDATAOpen(normalized, 0) >= 0 {
recovered := SanitizeLooseCDATA(normalized)
if recovered != normalized {
parsed = parseXMLToolCalls(recovered)
}
}
if len(parsed) == 0 {
return result
}
result.SawToolCallSyntax = true
calls, rejectedNames := filterToolCallsDetailed(parsed)
result.Calls = calls
result.RejectedToolNames = rejectedNames
result.RejectedByPolicy = len(rejectedNames) > 0 && len(calls) == 0
return result
}
func filterToolCallsDetailed(parsed []ParsedToolCall) ([]ParsedToolCall, []string) {
out := make([]ParsedToolCall, 0, len(parsed))
for _, tc := range parsed {
if tc.Name == "" {
continue
}
if tc.Input == nil {
tc.Input = map[string]any{}
}
out = append(out, tc)
}
return out, nil
}
func looksLikeToolCallSyntax(text string) bool {
hasDSML, hasCanonical := ContainsToolCallWrapperSyntaxOutsideIgnored(text)
return hasDSML || hasCanonical
}
func stripFencedCodeBlocks(text string) string {
if text == "" {
return ""
}
var b strings.Builder
b.Grow(len(text))
lines := strings.SplitAfter(text, "\n")
inFence := false
fenceMarker := ""
inCDATA := false
cdataFenceMarker := ""
// Track builder length when a fence opens so we can preserve content
// collected before the unclosed fence.
beforeFenceLen := 0
for _, line := range lines {
if inCDATA || cdataStartsBeforeFence(line) {
b.WriteString(line)
inCDATA, cdataFenceMarker = updateCDATAStateForStrip(inCDATA, cdataFenceMarker, line)
continue
}
trimmed := strings.TrimLeft(line, " \t")
if !inFence {
if marker, ok := parseFenceOpen(trimmed); ok {
inFence = true
fenceMarker = marker
beforeFenceLen = b.Len()
continue
}
b.WriteString(line)
continue
}
if isFenceClose(trimmed, fenceMarker) {
inFence = false
fenceMarker = ""
}
}
if inFence {
// Unclosed fence: preserve content that was collected before the
// fence started rather than dropping everything.
result := b.String()
if beforeFenceLen > 0 && beforeFenceLen <= len(result) {
return result[:beforeFenceLen]
}
return ""
}
return b.String()
}
func markdownCodeSpanEnd(text string, start int) (int, bool) {
if start < 0 || start >= len(text) || text[start] != '`' {
return start, false
}
count := countLeadingFenceChars(text[start:], '`')
if count == 0 {
return start, false
}
search := start + count
for search < len(text) {
if text[search] != '`' {
search++
continue
}
run := countLeadingFenceChars(text[search:], '`')
if run == count {
return search + run, true
}
search += run
}
return start, false
}
func cdataStartsBeforeFence(line string) bool {
cdataIdx := indexToolCDATAOpen(line, 0)
if cdataIdx < 0 {
return false
}
fenceIdx := firstFenceMarkerIndex(line)
return fenceIdx < 0 || cdataIdx < fenceIdx
}
func firstFenceMarkerIndex(line string) int {
idxBacktick := strings.Index(line, "```")
idxTilde := strings.Index(line, "~~~")
switch {
case idxBacktick < 0:
return idxTilde
case idxTilde < 0:
return idxBacktick
case idxBacktick < idxTilde:
return idxBacktick
default:
return idxTilde
}
}
func updateCDATAStateForStrip(inCDATA bool, cdataFenceMarker, line string) (bool, string) {
pos := 0
state := inCDATA
fenceMarker := cdataFenceMarker
lineForFence := line
if !state {
start := indexToolCDATAOpen(line, pos)
if start < 0 {
return false, ""
}
pos = start + toolCDATAOpenLenAt(line, start)
if pos > len(line) {
pos = len(line)
}
state = true
lineForFence = line[pos:]
}
if !state {
return false, ""
}
trimmed := strings.TrimLeft(lineForFence, " \t")
if fenceMarker == "" {
if marker, ok := parseFenceOpen(trimmed); ok {
fenceMarker = marker
}
} else if isFenceClose(trimmed, fenceMarker) {
fenceMarker = ""
}
for pos < len(line) {
endPos := -1
closeLen := 0
for search := pos; search < len(line); search++ {
if foundLen := toolCDATACloseLenAt(line, search); foundLen > 0 {
endPos = search
closeLen = foundLen
break
}
}
if endPos < 0 {
return true, fenceMarker
}
pos = endPos + closeLen
if pos > len(line) {
pos = len(line)
}
if fenceMarker != "" {
continue
}
if cdataEndLooksStructural(line, pos) || strings.TrimSpace(line[pos:]) == "" {
state = false
for pos < len(line) {
start := indexToolCDATAOpen(line, pos)
if start < 0 {
return false, ""
}
pos = start + toolCDATAOpenLenAt(line, start)
if pos > len(line) {
pos = len(line)
}
state = true
trimmedTail := strings.TrimLeft(line[pos:], " \t")
if marker, ok := parseFenceOpen(trimmedTail); ok {
fenceMarker = marker
} else {
fenceMarker = ""
}
break
}
continue
}
}
return state, fenceMarker
}
func parseFenceOpen(line string) (string, bool) {
if len(line) < 3 {
return "", false
}
ch := line[0]
if ch != '`' && ch != '~' {
return "", false
}
count := countLeadingFenceChars(line, ch)
if count < 3 {
return "", false
}
return strings.Repeat(string(ch), count), true
}
func isFenceClose(line, marker string) bool {
if marker == "" {
return false
}
ch := marker[0]
if line == "" || line[0] != ch {
return false
}
count := countLeadingFenceChars(line, ch)
if count < len(marker) {
return false
}
rest := strings.TrimSpace(line[count:])
return rest == ""
}
func countLeadingFenceChars(line string, ch byte) int {
count := 0
for count < len(line) && line[count] == ch {
count++
}
return count
}