mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-06 01:15:29 +08:00
- 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>
220 lines
4.6 KiB
Go
220 lines
4.6 KiB
Go
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
|
||
}
|
||
}
|