Files
ds2api/internal/toolcall/toolcalls_scan.go
CJACK 2d5d211a7a refactor: unify Go/Node XML tool markup scanning and expand DSML alias support
- Add shared ToolMarkupTag scanner (toolcalls_scan.go) replacing hardcoded alias tables
- Support DSML collapsed tag names (<DSMLtool_calls>, <DSMLinvoke>, <DSMLparameter>)
- Parse JSON literal values from parameter bodies (123→number, true→bool, null)
- Recover unclosed CDATA in final parse/flush via SanitizeLooseCDATA
- Align Go and Node implementations (scanToolMarkupTagAt, findMatchingToolMarkupClose)
- Reject bare <invoke> as unsupported syntax, only tool_calls wrapper triggers tool path
- Update API.md and toolcall-semantics.md documentation

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 17:53:59 +08:00

220 lines
4.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package toolcall
import "strings"
var toolMarkupNames = []string{"tool_calls", "invoke", "parameter"}
type ToolMarkupTag struct {
Start int
End int
NameStart int
NameEnd int
Name string
Closing bool
SelfClosing bool
DSMLLike bool
Canonical bool
}
func ContainsToolMarkupSyntaxOutsideIgnored(text string) (hasDSML, hasCanonical bool) {
lower := strings.ToLower(text)
for i := 0; i < len(text); {
next, advanced, blocked := skipXMLIgnoredSection(lower, i)
if blocked {
return hasDSML, hasCanonical
}
if advanced {
i = next
continue
}
if tag, ok := scanToolMarkupTagAt(text, i); ok {
if tag.DSMLLike {
hasDSML = true
} else {
hasCanonical = true
}
if hasDSML && hasCanonical {
return true, true
}
i = tag.End + 1
continue
}
i++
}
return hasDSML, hasCanonical
}
func ContainsToolCallWrapperSyntaxOutsideIgnored(text string) (hasDSML, hasCanonical bool) {
lower := strings.ToLower(text)
for i := 0; i < len(text); {
next, advanced, blocked := skipXMLIgnoredSection(lower, i)
if blocked {
return hasDSML, hasCanonical
}
if advanced {
i = next
continue
}
if tag, ok := scanToolMarkupTagAt(text, i); ok {
if tag.Name != "tool_calls" {
i = tag.End + 1
continue
}
if tag.DSMLLike {
hasDSML = true
} else {
hasCanonical = true
}
if hasDSML && hasCanonical {
return true, true
}
i = tag.End + 1
continue
}
i++
}
return hasDSML, hasCanonical
}
func FindToolMarkupTagOutsideIgnored(text string, start int) (ToolMarkupTag, bool) {
lower := strings.ToLower(text)
for i := maxInt(start, 0); i < len(text); {
next, advanced, blocked := skipXMLIgnoredSection(lower, i)
if blocked {
return ToolMarkupTag{}, false
}
if advanced {
i = next
continue
}
if tag, ok := scanToolMarkupTagAt(text, i); ok {
return tag, true
}
i++
}
return ToolMarkupTag{}, false
}
func FindMatchingToolMarkupClose(text string, open ToolMarkupTag) (ToolMarkupTag, bool) {
if text == "" || open.Name == "" || open.Closing {
return ToolMarkupTag{}, false
}
depth := 1
for pos := open.End + 1; pos < len(text); {
tag, ok := FindToolMarkupTagOutsideIgnored(text, pos)
if !ok {
return ToolMarkupTag{}, false
}
if tag.Name != open.Name {
pos = tag.End + 1
continue
}
if tag.Closing {
depth--
if depth == 0 {
return tag, true
}
} else if !tag.SelfClosing {
depth++
}
pos = tag.End + 1
}
return ToolMarkupTag{}, false
}
func scanToolMarkupTagAt(text string, start int) (ToolMarkupTag, bool) {
if start < 0 || start >= len(text) || text[start] != '<' {
return ToolMarkupTag{}, false
}
lower := strings.ToLower(text)
i := start + 1
closing := false
if i < len(text) && text[i] == '/' {
closing = true
i++
}
dsmlLike := false
if next, ok := consumeToolMarkupPipe(text, i); ok {
dsmlLike = true
i = next
}
if strings.HasPrefix(lower[i:], "dsml") {
dsmlLike = true
i += len("dsml")
for next, ok := consumeToolMarkupSeparator(text, i); ok; next, ok = consumeToolMarkupSeparator(text, i) {
i = next
}
}
name, nameLen := matchToolMarkupName(lower, i)
if nameLen == 0 {
return ToolMarkupTag{}, false
}
nameEnd := i + nameLen
if !hasToolMarkupBoundary(text, nameEnd) {
return ToolMarkupTag{}, false
}
end := findXMLTagEnd(text, nameEnd)
if end < 0 {
return ToolMarkupTag{}, false
}
trimmed := strings.TrimSpace(text[start : end+1])
return ToolMarkupTag{
Start: start,
End: end,
NameStart: i,
NameEnd: nameEnd,
Name: name,
Closing: closing,
SelfClosing: strings.HasSuffix(trimmed, "/>"),
DSMLLike: dsmlLike,
Canonical: !dsmlLike,
}, true
}
func matchToolMarkupName(lower string, start int) (string, int) {
for _, name := range toolMarkupNames {
if strings.HasPrefix(lower[start:], name) {
return name, len(name)
}
}
return "", 0
}
func consumeToolMarkupPipe(text string, idx int) (int, bool) {
if idx >= len(text) {
return idx, false
}
if text[idx] == '|' {
return idx + 1, true
}
if strings.HasPrefix(text[idx:], "") {
return idx + len(""), true
}
return idx, false
}
func consumeToolMarkupSeparator(text string, idx int) (int, bool) {
if idx >= len(text) {
return idx, false
}
if text[idx] == ' ' || text[idx] == '\t' || text[idx] == '\r' || text[idx] == '\n' {
return idx + 1, true
}
if next, ok := consumeToolMarkupPipe(text, idx); ok {
return next, true
}
return idx, false
}
func hasToolMarkupBoundary(text string, idx int) bool {
if idx >= len(text) {
return true
}
switch text[idx] {
case ' ', '\t', '\n', '\r', '>', '/':
return true
default:
return false
}
}