Skip to content

Commit 22efff8

Browse files
nguyenha935GoClaw Operator
andauthored
fix: filter streamed thinking tags (#1300)
Co-authored-by: GoClaw Operator <operator@goclaw>
1 parent 85b17f6 commit 22efff8

13 files changed

Lines changed: 453 additions & 28 deletions

internal/channels/events.go

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -98,10 +98,10 @@ func (m *Manager) HandleAgentEvent(eventType, runID string, payload any) {
9898
currentStream := rc.stream
9999
rc.stream = nil
100100
rc.inToolPhase = true
101-
rc.thinkingDone = false // allow new thinking in next iteration
102-
rc.thinkingBuffer = "" // reset thinking buffer for new iteration
103-
rc.hasThinking = false // new iteration starts fresh
104-
rc.tagParseSkipped = false // re-enable tag parsing for next iteration
101+
rc.thinkingDone = false // allow new thinking in next iteration
102+
rc.thinkingBuffer = "" // reset thinking buffer for new iteration
103+
rc.hasThinking = false // new iteration starts fresh
104+
rc.tagParsePending = "" // discard any incomplete tag from the previous iteration
105105
rc.mu.Unlock()
106106
if currentStream != nil {
107107
if err := currentStream.Stop(ctx); err != nil {
@@ -135,19 +135,22 @@ func (m *Manager) HandleAgentEvent(eventType, runID string, payload any) {
135135
// Fallback <think> tag parsing: for providers that embed thinking
136136
// in the content stream (DeepSeek-via-OpenRouter, Qwen, some Ollama models).
137137
// Only activates when no native ChatEventThinking was received.
138-
if !rc.hasThinking && !rc.thinkingDone && !rc.tagParseSkipped {
139-
candidate := rc.streamBuffer + content
138+
if !rc.hasThinking && !rc.thinkingDone {
139+
previousAnswer := rc.streamBuffer
140+
candidate := rc.streamBuffer + rc.tagParsePending + content
140141
split := SplitThinkTags(candidate)
141-
if split.Thinking != "" {
142+
if split.Found || split.Pending != "" {
142143
// Found think tags — commit to buffer and route or suppress reasoning
143144
// before any tagged content can leak into the answer lane.
144145
displayReasoningInStream := rc.ReasoningDelivery.ShowInChannel && !rc.ReasoningDelivery.BubbleDelivery && sc.ReasoningStreamEnabled()
145146
previousThinking := rc.thinkingBuffer
146-
rc.streamBuffer = candidate
147147
rc.thinkingBuffer = split.Thinking
148148
thinkText := rc.thinkingBuffer
149149
currentStream := rc.stream
150-
if split.Partial {
150+
if split.Partial || split.Pending != "" {
151+
rc.streamBuffer = split.Answer
152+
rc.tagParsePending = split.Pending
153+
answerText := rc.streamBuffer
151154
// Still inside <think> — wait for the close tag before streaming
152155
// answer content. Native thinking uses hasThinking; tag parsing
153156
// keeps it false until close so later chunks continue parsing.
@@ -158,9 +161,12 @@ func (m *Manager) HandleAgentEvent(eventType, runID string, payload any) {
158161
}
159162
} else if displayReasoningInStream && currentStream != nil {
160163
currentStream.Update(ctx, formatReasoningPreview(thinkText))
164+
} else if currentStream != nil {
165+
currentStream.Update(ctx, answerText)
161166
}
162167
break
163168
}
169+
rc.tagParsePending = ""
164170
// Tag closed — transition to answer, or strip reasoning entirely
165171
// when Show Reasoning is off.
166172
answerText := split.Answer
@@ -177,7 +183,7 @@ func (m *Manager) HandleAgentEvent(eventType, runID string, payload any) {
177183
m.flushReasoningBubbles(runID)
178184
}
179185

180-
if !displayReasoningInStream {
186+
if previousAnswer != "" || !displayReasoningInStream {
181187
if reasoningStream != nil && answerText != "" {
182188
reasoningStream.Update(ctx, answerText)
183189
}
@@ -209,9 +215,6 @@ func (m *Manager) HandleAgentEvent(eventType, runID string, payload any) {
209215
}
210216
break
211217
}
212-
// No think tags found — mark as skipped so we don't re-parse.
213-
// Don't commit to streamBuffer here — the normal flow below appends content.
214-
rc.tagParseSkipped = true
215218
}
216219

217220
// Reasoning→answer transition: first chunk after native thinking events.

internal/channels/manager.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ type RunContext struct {
5454
thinkingBuffer string // accumulated thinking/reasoning text
5555
hasThinking bool // true if any thinking events received this iteration
5656
thinkingDone bool // true after first chunk arrives (reasoning→answer transition complete)
57-
tagParseSkipped bool // true after first chunk with no <think> tags (skip re-parsing)
57+
tagParsePending string // raw trailing text withheld because it may be a split <think> tag
5858
reasoningBubbles *reasoningBubbleBuffer
5959
reasoningBubbleTimer *time.Timer
6060
}

internal/channels/reasoning_bubbles.go

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -105,25 +105,36 @@ func (m *Manager) appendReasoningBubbleFromThinkTagChunk(runID string, rc *RunCo
105105
var done bool
106106

107107
rc.mu.Lock()
108-
if rc.thinkingDone || rc.tagParseSkipped {
108+
if rc.thinkingDone {
109109
rc.mu.Unlock()
110110
return
111111
}
112112

113-
candidate := rc.streamBuffer + content
113+
candidate := rc.streamBuffer + rc.tagParsePending + content
114114
split := SplitThinkTags(candidate)
115-
if split.Thinking == "" {
116-
rc.tagParseSkipped = true
115+
if !split.Found && split.Pending == "" {
116+
rc.streamBuffer = split.Answer
117+
rc.mu.Unlock()
118+
return
119+
}
120+
if !split.Found && split.Pending != "" {
121+
rc.streamBuffer = split.Answer
122+
rc.tagParsePending = split.Pending
117123
rc.mu.Unlock()
118124
return
119125
}
126+
if split.Pending != "" {
127+
rc.streamBuffer = split.Answer
128+
rc.tagParsePending = split.Pending
129+
}
120130

121131
previousThinking := rc.thinkingBuffer
122-
rc.streamBuffer = candidate
132+
rc.streamBuffer = split.Answer
133+
rc.tagParsePending = split.Pending
123134
rc.thinkingBuffer = split.Thinking
124135
if !split.Partial {
125136
rc.thinkingDone = true
126-
rc.streamBuffer = split.Answer
137+
rc.tagParsePending = ""
127138
done = true
128139
}
129140
delta = reasoningDelta(previousThinking, split.Thinking)

internal/channels/reasoning_delivery_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,57 @@ func TestHandleAgentEvent_ReasoningOffStripsThinkTagsFromStreamingAnswer(t *test
203203
}
204204
}
205205

206+
func TestHandleAgentEvent_ReasoningOffStripsLateThinkTagsFromStreamingAnswer(t *testing.T) {
207+
mb := bus.New()
208+
mgr := NewManager(mb)
209+
ch := &reasoningStreamingTestChannel{name: "test", reasoningEnabled: true}
210+
mgr.RegisterChannel("test", ch)
211+
delivery := ResolveReasoningDelivery(ReasoningDeliveryOff, nil)
212+
mgr.RegisterRunWithBehavior("run-1", "test", "chat-1", "msg-1", nil, uuid.Nil, true, false, true, ResolvedChatBehavior{}, delivery)
213+
214+
mgr.HandleAgentEvent(protocol.AgentEventRunStarted, "run-1", nil)
215+
mgr.HandleAgentEvent(protocol.ChatEventChunk, "run-1", map[string]string{"content": "Visible prefix "})
216+
mgr.HandleAgentEvent(protocol.ChatEventChunk, "run-1", map[string]string{"content": "<thinking>hidden reasoning</thinking> visible answer"})
217+
218+
if len(ch.streams) != 1 {
219+
t.Fatalf("streams = %d, want answer stream only", len(ch.streams))
220+
}
221+
got := ch.streams[0].lastUpdate()
222+
if strings.Contains(got, "<thinking>") || strings.Contains(got, "hidden reasoning") {
223+
t.Fatalf("stream leaked thinking tag content: %q", got)
224+
}
225+
if got != "Visible prefix visible answer" {
226+
t.Fatalf("stream updates = %q, want clean answer", got)
227+
}
228+
}
229+
230+
func TestHandleAgentEvent_ReasoningOffStripsSplitThinkTagsFromStreamingAnswer(t *testing.T) {
231+
mb := bus.New()
232+
mgr := NewManager(mb)
233+
ch := &reasoningStreamingTestChannel{name: "test", reasoningEnabled: true}
234+
mgr.RegisterChannel("test", ch)
235+
delivery := ResolveReasoningDelivery(ReasoningDeliveryOff, nil)
236+
mgr.RegisterRunWithBehavior("run-1", "test", "chat-1", "msg-1", nil, uuid.Nil, true, false, true, ResolvedChatBehavior{}, delivery)
237+
238+
mgr.HandleAgentEvent(protocol.AgentEventRunStarted, "run-1", nil)
239+
mgr.HandleAgentEvent(protocol.ChatEventChunk, "run-1", map[string]string{"content": "Visible "})
240+
mgr.HandleAgentEvent(protocol.ChatEventChunk, "run-1", map[string]string{"content": "<think"})
241+
242+
if got := ch.streams[0].lastUpdate(); got != "Visible " {
243+
t.Fatalf("partial tag name leaked before completion: %q", got)
244+
}
245+
246+
mgr.HandleAgentEvent(protocol.ChatEventChunk, "run-1", map[string]string{"content": "ing>hidden reasoning</thinking> answer"})
247+
248+
got := ch.streams[0].lastUpdate()
249+
if strings.Contains(got, "<thinking>") || strings.Contains(got, "hidden reasoning") {
250+
t.Fatalf("stream leaked split thinking tag content: %q", got)
251+
}
252+
if got != "Visible answer" {
253+
t.Fatalf("stream updates = %q, want clean answer", got)
254+
}
255+
}
256+
206257
func TestHandleAgentEvent_AlwaysBubblesExtractsThinkTagsFromStreamingChunk(t *testing.T) {
207258
mb := bus.New()
208259
mgr := NewManager(mb)
@@ -267,3 +318,10 @@ func (s *reasoningRecordingStream) Stop(context.Context) error {
267318
}
268319

269320
func (s *reasoningRecordingStream) MessageID() int { return 1 }
321+
322+
func (s *reasoningRecordingStream) lastUpdate() string {
323+
if len(s.updates) == 0 {
324+
return ""
325+
}
326+
return s.updates[len(s.updates)-1]
327+
}

internal/channels/think_tag_parser.go

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ type ThinkTagSplit struct {
1717
Thinking string // content inside <think> tags (empty if no tags found)
1818
Answer string // content outside <think> tags
1919
Partial bool // true if an unclosed <think> tag is present (buffer for more)
20+
Found bool // true if an opening think tag was found
21+
Pending string // trailing text withheld because it may be a split opening tag
2022
}
2123

2224
// SplitThinkTags extracts thinking content from <think>...</think> tags.
@@ -28,11 +30,13 @@ func SplitThinkTags(text string) ThinkTagSplit {
2830
if !strings.Contains(lower, "<think") &&
2931
!strings.Contains(lower, "<thought") &&
3032
!strings.Contains(lower, "<antthinking") {
31-
return ThinkTagSplit{Answer: text}
33+
answer, pending := splitTrailingThinkTagPrefix(text)
34+
return ThinkTagSplit{Answer: answer, Pending: pending}
3235
}
3336

3437
var thinking, answer strings.Builder
3538
remaining := text
39+
found := false
3640

3741
for remaining != "" {
3842
// Find opening tag
@@ -47,6 +51,7 @@ func SplitThinkTags(text string) ThinkTagSplit {
4751
if openLoc[0] > 0 {
4852
answer.WriteString(remaining[:openLoc[0]])
4953
}
54+
found = true
5055

5156
// Find closing tag after the opening tag
5257
afterOpen := remaining[openLoc[1]:]
@@ -58,6 +63,8 @@ func SplitThinkTags(text string) ThinkTagSplit {
5863
Thinking: thinking.String(),
5964
Answer: answer.String(),
6065
Partial: true,
66+
Found: true,
67+
Pending: remaining[openLoc[0]:],
6168
}
6269
}
6370

@@ -66,8 +73,46 @@ func SplitThinkTags(text string) ThinkTagSplit {
6673
remaining = afterOpen[closeLoc[1]:]
6774
}
6875

76+
answerText := answer.String()
77+
pending := ""
78+
if !found {
79+
answerText, pending = splitTrailingThinkTagPrefix(answerText)
80+
}
81+
6982
return ThinkTagSplit{
7083
Thinking: thinking.String(),
71-
Answer: answer.String(),
84+
Answer: answerText,
85+
Found: found,
86+
Pending: pending,
87+
}
88+
}
89+
90+
func splitTrailingThinkTagPrefix(text string) (answer, pending string) {
91+
idx := strings.LastIndex(text, "<")
92+
if idx < 0 {
93+
return text, ""
94+
}
95+
suffix := text[idx:]
96+
if strings.Contains(suffix, ">") || !isPossibleThinkOpenPrefix(suffix) {
97+
return text, ""
98+
}
99+
return text[:idx], suffix
100+
}
101+
102+
func isPossibleThinkOpenPrefix(suffix string) bool {
103+
lower := strings.ToLower(suffix)
104+
if !strings.HasPrefix(lower, "<") {
105+
return false
106+
}
107+
rest := strings.TrimLeft(lower[1:], " \t\r\n")
108+
normalized := "<" + rest
109+
if normalized == "<" {
110+
return true
111+
}
112+
for _, tag := range []string{"<think", "<thinking", "<thought", "<antthinking"} {
113+
if strings.HasPrefix(tag, normalized) || strings.HasPrefix(normalized, tag) {
114+
return true
115+
}
72116
}
117+
return false
73118
}

internal/pipeline/observe_stage.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,16 +55,17 @@ func (s *ObserveStage) drainInjectedMessages() []providers.Message {
5555
}
5656

5757
func (s *ObserveStage) observeFinalResponse(state *RunState, resp *providers.ChatResponse, injected []providers.Message) {
58+
content, thinking := splitTaggedThinkingContent(resp.Content, resp.Thinking)
5859
if len(injected) == 0 {
59-
state.Observe.FinalContent = resp.Content
60-
state.Observe.FinalThinking = resp.Thinking
60+
state.Observe.FinalContent = content
61+
state.Observe.FinalThinking = thinking
6162
return
6263
}
6364

6465
state.Messages.AppendPending(providers.Message{
6566
Role: "assistant",
66-
Content: resp.Content,
67-
Thinking: resp.Thinking,
67+
Content: content,
68+
Thinking: thinking,
6869
Transient: true,
6970
})
7071
appendPendingMessages(state, injected)

internal/pipeline/stages_test.go

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1981,6 +1981,27 @@ func TestObserveStage_LateInjectionAfterFinalContinuesRun(t *testing.T) {
19811981
}
19821982
}
19831983

1984+
func TestObserveStage_MovesTaggedThinkingIntoFinalThinking(t *testing.T) {
1985+
t.Parallel()
1986+
stage := NewObserveStage(&PipelineDeps{})
1987+
state := defaultState()
1988+
state.Think.LastResponse = &providers.ChatResponse{
1989+
Content: "Visible <thinking>hidden reasoning</thinking> answer",
1990+
Thinking: "native",
1991+
}
1992+
1993+
err := stage.Execute(context.Background(), state)
1994+
if err != nil {
1995+
t.Fatalf("Execute() error: %v", err)
1996+
}
1997+
if state.Observe.FinalContent != "Visible answer" {
1998+
t.Errorf("FinalContent = %q, want %q", state.Observe.FinalContent, "Visible answer")
1999+
}
2000+
if state.Observe.FinalThinking != "native\nhidden reasoning" {
2001+
t.Errorf("FinalThinking = %q, want %q", state.Observe.FinalThinking, "native\nhidden reasoning")
2002+
}
2003+
}
2004+
19842005
func TestObserveStage_FinalContent_SetWhenNoToolCalls(t *testing.T) {
19852006
t.Parallel()
19862007
deps := &PipelineDeps{}
@@ -2440,6 +2461,35 @@ func TestFinalizeStage_SanitizesContent(t *testing.T) {
24402461
}
24412462
}
24422463

2464+
func TestFinalizeStage_SuppressesSilentReplyAfterFlush(t *testing.T) {
2465+
t.Parallel()
2466+
finalContent := "This is directed at Bảo Ly Content, not me. I should stay silent.NO_REPLY"
2467+
var flushed []providers.Message
2468+
deps := &PipelineDeps{
2469+
IsSilentReply: func(content string) bool {
2470+
return content == finalContent
2471+
},
2472+
FlushMessages: func(_ context.Context, _ string, messages []providers.Message) error {
2473+
flushed = append(flushed, messages...)
2474+
return nil
2475+
},
2476+
}
2477+
stage := NewFinalizeStage(deps)
2478+
state := defaultState()
2479+
state.Observe.FinalContent = finalContent
2480+
2481+
err := stage.Execute(context.Background(), state)
2482+
if err != nil {
2483+
t.Fatalf("Execute() error: %v", err)
2484+
}
2485+
if state.Observe.FinalContent != "" {
2486+
t.Fatalf("FinalContent = %q, want empty after silent reply suppression", state.Observe.FinalContent)
2487+
}
2488+
if len(flushed) == 0 || flushed[len(flushed)-1].Content != finalContent {
2489+
t.Fatalf("flushed final content = %#v, want original silent reply persisted before suppression", flushed)
2490+
}
2491+
}
2492+
24432493
func TestFinalizeStage_DeduplicatesMediaByPath(t *testing.T) {
24442494
t.Parallel()
24452495
deps := &PipelineDeps{}
@@ -3332,4 +3382,3 @@ func TestToolStage_Parallel_DefersNonToolMessages(t *testing.T) {
33323382
t.Errorf("pending[3].Content = %q, want nudge", pending[3].Content)
33333383
}
33343384
}
3335-

0 commit comments

Comments
 (0)