Files
ds2api/internal/toolstream/tool_sieve_state.go
CJACK 77a47ada4e Fix tool detection when unclosed backtick precedes tool call
Handles cases where a stray backtick opens an inline code span but is never closed.
Previously, any subsequent XML tool tag was treated as inside markdown code and ignored.
Now, tool tags are detected after an unclosed backtick, and the markdown state is reset
when the backtick is confirmed to be literal text at stream boundaries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-10 18:41:51 +08:00

258 lines
5.5 KiB
Go

package toolstream
import (
"ds2api/internal/toolcall"
"strings"
)
type State struct {
pending strings.Builder
capture strings.Builder
capturing bool
codeFenceStack []int
codeFencePendingTicks int
codeFencePendingTildes int
codeFenceNotLineStart bool // inverted: zero-value false means "at line start"
markdownCodeSpanTicks int
pendingToolRaw string
pendingToolCalls []toolcall.ParsedToolCall
disableDeltas bool
toolNameSent bool
toolName string
toolArgsStart int
toolArgsSent int
toolArgsString bool
toolArgsDone bool
}
type Event struct {
Content string
ToolCalls []toolcall.ParsedToolCall
ToolCallDeltas []ToolCallDelta
}
type ToolCallDelta struct {
Index int
Name string
Arguments string
}
func (s *State) resetIncrementalToolState() {
s.disableDeltas = false
s.toolNameSent = false
s.toolName = ""
s.toolArgsStart = -1
s.toolArgsSent = -1
s.toolArgsString = false
s.toolArgsDone = false
}
func (s *State) noteText(content string) {
if !hasMeaningfulText(content) {
return
}
updateMarkdownCodeSpanState(s, content)
updateCodeFenceState(s, content)
}
func hasMeaningfulText(text string) bool {
return strings.TrimSpace(text) != ""
}
func insideCodeFenceWithState(state *State, text string) bool {
if state == nil {
return insideCodeFence(text)
}
simulated := simulateCodeFenceState(
state.codeFenceStack,
state.codeFencePendingTicks,
state.codeFencePendingTildes,
!state.codeFenceNotLineStart,
text,
)
return len(simulated.stack) > 0
}
func insideCodeFence(text string) bool {
if text == "" {
return false
}
return len(simulateCodeFenceState(nil, 0, 0, true, text).stack) > 0
}
func updateMarkdownCodeSpanState(state *State, text string) {
if state == nil || !hasMeaningfulText(text) {
return
}
state.markdownCodeSpanTicks = simulateMarkdownCodeSpanTicks(state, state.markdownCodeSpanTicks, text)
}
func simulateMarkdownCodeSpanTicks(state *State, initialTicks int, text string) int {
ticks := initialTicks
for i := 0; i < len(text); {
if text[i] != '`' {
i++
continue
}
run := countBacktickRun(text, i)
if ticks == 0 {
if run >= 3 && atMarkdownFenceLineStart(text, i) {
i += run
continue
}
if state != nil && insideCodeFenceWithState(state, text[:i]) {
i += run
continue
}
ticks = run
} else if run == ticks {
ticks = 0
}
i += run
}
return ticks
}
func countBacktickRun(text string, start int) int {
count := 0
for start+count < len(text) && text[start+count] == '`' {
count++
}
return count
}
func atMarkdownFenceLineStart(text string, idx int) bool {
for i := idx - 1; i >= 0; i-- {
switch text[i] {
case ' ', '\t':
continue
case '\n', '\r':
return true
default:
return false
}
}
return true
}
func updateCodeFenceState(state *State, text string) {
if state == nil || !hasMeaningfulText(text) {
return
}
next := simulateCodeFenceState(
state.codeFenceStack,
state.codeFencePendingTicks,
state.codeFencePendingTildes,
!state.codeFenceNotLineStart,
text,
)
state.codeFenceStack = next.stack
state.codeFencePendingTicks = next.pendingTicks
state.codeFencePendingTildes = next.pendingTildes
state.codeFenceNotLineStart = !next.lineStart
}
type codeFenceSimulation struct {
stack []int
pendingTicks int
pendingTildes int
lineStart bool
}
func simulateCodeFenceState(stack []int, pendingTicks, pendingTildes int, lineStart bool, text string) codeFenceSimulation {
chunk := text
nextStack := append([]int(nil), stack...)
ticks := pendingTicks
tildes := pendingTildes
atLineStart := lineStart
flushPending := func() {
if ticks > 0 {
if atLineStart && ticks >= 3 {
applyFenceMarker(&nextStack, ticks) // positive = backtick
}
atLineStart = false
ticks = 0
}
if tildes > 0 {
if atLineStart && tildes >= 3 {
applyFenceMarker(&nextStack, -tildes) // negative = tilde
}
atLineStart = false
tildes = 0
}
}
for i := 0; i < len(chunk); i++ {
ch := chunk[i]
if ch == '`' {
if tildes > 0 {
// Mixed chars — flush tildes first.
flushPending()
}
ticks++
continue
}
if ch == '~' {
if ticks > 0 {
flushPending()
}
tildes++
continue
}
flushPending()
switch ch {
case '\n', '\r':
atLineStart = true
case ' ', '\t':
if atLineStart {
continue
}
atLineStart = false
default:
atLineStart = false
}
}
return codeFenceSimulation{
stack: nextStack,
pendingTicks: ticks,
pendingTildes: tildes,
lineStart: atLineStart,
}
}
// applyFenceMarker pushes or pops a fence marker on the stack.
// Positive values represent backtick fences, negative represent tilde fences.
// A closing marker must match the sign (type) of the opening marker.
func applyFenceMarker(stack *[]int, marker int) {
if stack == nil || marker == 0 {
return
}
if len(*stack) == 0 {
*stack = append(*stack, marker)
return
}
top := (*stack)[len(*stack)-1]
// Signs must match: backtick closes backtick, tilde closes tilde.
sameType := (top > 0 && marker > 0) || (top < 0 && marker < 0)
if !sameType {
// Different fence type — treat as nested.
*stack = append(*stack, marker)
return
}
absMarker := marker
absTop := top
if absMarker < 0 {
absMarker = -absMarker
}
if absTop < 0 {
absTop = -absTop
}
if absMarker >= absTop {
*stack = (*stack)[:len(*stack)-1]
return
}
*stack = append(*stack, marker)
}