mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-05 00:45: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>
221 lines
5.5 KiB
Go
221 lines
5.5 KiB
Go
package toolstream
|
|
|
|
import (
|
|
"strings"
|
|
|
|
"ds2api/internal/toolcall"
|
|
)
|
|
|
|
func ProcessChunk(state *State, chunk string, toolNames []string) []Event {
|
|
if state == nil {
|
|
return nil
|
|
}
|
|
if chunk != "" {
|
|
state.pending.WriteString(chunk)
|
|
}
|
|
events := make([]Event, 0, 2)
|
|
if len(state.pendingToolCalls) > 0 {
|
|
events = append(events, Event{ToolCalls: state.pendingToolCalls})
|
|
state.pendingToolRaw = ""
|
|
state.pendingToolCalls = nil
|
|
}
|
|
|
|
for {
|
|
if state.capturing {
|
|
if state.pending.Len() > 0 {
|
|
state.capture.WriteString(state.pending.String())
|
|
state.pending.Reset()
|
|
}
|
|
prefix, calls, suffix, ready := consumeToolCapture(state, toolNames)
|
|
if !ready {
|
|
break
|
|
}
|
|
captured := state.capture.String()
|
|
state.capture.Reset()
|
|
state.capturing = false
|
|
state.resetIncrementalToolState()
|
|
if len(calls) > 0 {
|
|
if prefix != "" {
|
|
state.noteText(prefix)
|
|
events = append(events, Event{Content: prefix})
|
|
}
|
|
if suffix != "" {
|
|
state.pending.WriteString(suffix)
|
|
}
|
|
_ = captured
|
|
state.pendingToolCalls = calls
|
|
continue
|
|
}
|
|
if prefix != "" {
|
|
state.noteText(prefix)
|
|
events = append(events, Event{Content: prefix})
|
|
}
|
|
if suffix != "" {
|
|
state.pending.WriteString(suffix)
|
|
}
|
|
continue
|
|
}
|
|
|
|
pending := state.pending.String()
|
|
if pending == "" {
|
|
break
|
|
}
|
|
start := findToolSegmentStart(state, pending)
|
|
if start >= 0 {
|
|
prefix := pending[:start]
|
|
if prefix != "" {
|
|
state.noteText(prefix)
|
|
events = append(events, Event{Content: prefix})
|
|
}
|
|
state.pending.Reset()
|
|
state.capture.WriteString(pending[start:])
|
|
state.capturing = true
|
|
state.resetIncrementalToolState()
|
|
continue
|
|
}
|
|
|
|
safe, hold := splitSafeContentForToolDetection(state, pending)
|
|
if safe == "" {
|
|
break
|
|
}
|
|
state.pending.Reset()
|
|
state.pending.WriteString(hold)
|
|
state.noteText(safe)
|
|
events = append(events, Event{Content: safe})
|
|
}
|
|
|
|
return events
|
|
}
|
|
|
|
func Flush(state *State, toolNames []string) []Event {
|
|
if state == nil {
|
|
return nil
|
|
}
|
|
events := ProcessChunk(state, "", toolNames)
|
|
if len(state.pendingToolCalls) > 0 {
|
|
events = append(events, Event{ToolCalls: state.pendingToolCalls})
|
|
state.pendingToolRaw = ""
|
|
state.pendingToolCalls = nil
|
|
}
|
|
if state.capturing {
|
|
consumedPrefix, consumedCalls, consumedSuffix, ready := consumeToolCapture(state, toolNames)
|
|
if ready {
|
|
if consumedPrefix != "" {
|
|
state.noteText(consumedPrefix)
|
|
events = append(events, Event{Content: consumedPrefix})
|
|
}
|
|
if len(consumedCalls) > 0 {
|
|
events = append(events, Event{ToolCalls: consumedCalls})
|
|
}
|
|
if consumedSuffix != "" {
|
|
state.noteText(consumedSuffix)
|
|
events = append(events, Event{Content: consumedSuffix})
|
|
}
|
|
} else {
|
|
content := state.capture.String()
|
|
if content != "" {
|
|
recovered := toolcall.SanitizeLooseCDATA(content)
|
|
if recovered != content {
|
|
if prefix, calls, suffix, recoveredReady := consumeXMLToolCapture(recovered, toolNames); recoveredReady && len(calls) > 0 {
|
|
if prefix != "" {
|
|
state.noteText(prefix)
|
|
events = append(events, Event{Content: prefix})
|
|
}
|
|
events = append(events, Event{ToolCalls: calls})
|
|
if suffix != "" {
|
|
state.noteText(suffix)
|
|
events = append(events, Event{Content: suffix})
|
|
}
|
|
} else {
|
|
// If capture never resolved into a real tool call, release
|
|
// the buffered text instead of swallowing it.
|
|
state.noteText(content)
|
|
events = append(events, Event{Content: content})
|
|
}
|
|
} else {
|
|
// If capture never resolved into a real tool call, release the
|
|
// buffered text instead of swallowing it.
|
|
state.noteText(content)
|
|
events = append(events, Event{Content: content})
|
|
}
|
|
}
|
|
}
|
|
state.capture.Reset()
|
|
state.capturing = false
|
|
state.resetIncrementalToolState()
|
|
}
|
|
if state.pending.Len() > 0 {
|
|
content := state.pending.String()
|
|
// If pending never resolved into a real tool call, release it as text.
|
|
state.noteText(content)
|
|
events = append(events, Event{Content: content})
|
|
state.pending.Reset()
|
|
}
|
|
return events
|
|
}
|
|
|
|
func splitSafeContentForToolDetection(state *State, s string) (safe, hold string) {
|
|
if s == "" {
|
|
return "", ""
|
|
}
|
|
if xmlIdx := findPartialXMLToolTagStart(s); xmlIdx >= 0 {
|
|
if insideCodeFenceWithState(state, s[:xmlIdx]) {
|
|
return s, ""
|
|
}
|
|
if xmlIdx > 0 {
|
|
return s[:xmlIdx], s[xmlIdx:]
|
|
}
|
|
return "", s
|
|
}
|
|
return s, ""
|
|
}
|
|
|
|
func findToolSegmentStart(state *State, s string) int {
|
|
if s == "" {
|
|
return -1
|
|
}
|
|
lower := strings.ToLower(s)
|
|
offset := 0
|
|
for {
|
|
bestKeyIdx := -1
|
|
matchedTag := ""
|
|
for _, tag := range xmlToolTagsToDetect {
|
|
idx := strings.Index(lower[offset:], tag)
|
|
if idx >= 0 {
|
|
idx += offset
|
|
if bestKeyIdx < 0 || idx < bestKeyIdx {
|
|
bestKeyIdx = idx
|
|
matchedTag = tag
|
|
}
|
|
}
|
|
}
|
|
if bestKeyIdx < 0 {
|
|
return -1
|
|
}
|
|
if !insideCodeFenceWithState(state, s[:bestKeyIdx]) {
|
|
return bestKeyIdx
|
|
}
|
|
offset = bestKeyIdx + len(matchedTag)
|
|
}
|
|
}
|
|
|
|
func consumeToolCapture(state *State, toolNames []string) (prefix string, calls []toolcall.ParsedToolCall, suffix string, ready bool) {
|
|
captured := state.capture.String()
|
|
if captured == "" {
|
|
return "", nil, "", false
|
|
}
|
|
|
|
// XML tool call extraction only.
|
|
if xmlPrefix, xmlCalls, xmlSuffix, xmlReady := consumeXMLToolCapture(captured, toolNames); xmlReady {
|
|
return xmlPrefix, xmlCalls, xmlSuffix, true
|
|
}
|
|
// If XML tags are present but block is incomplete, keep buffering.
|
|
if hasOpenXMLToolTag(captured) {
|
|
return "", nil, "", false
|
|
}
|
|
if shouldKeepBareInvokeCapture(captured) {
|
|
return "", nil, "", false
|
|
}
|
|
return captured, nil, "", true
|
|
}
|