diff --git a/internal/ai/chat/agentic.go b/internal/ai/chat/agentic.go index 52e319b12..da00004cd 100644 --- a/internal/ai/chat/agentic.go +++ b/internal/ai/chat/agentic.go @@ -267,6 +267,18 @@ func (a *AgenticLoop) executeWithTools(ctx context.Context, sessionID string, me switch event.Type { case "content": if data, ok := event.Data.(providers.ContentEvent); ok { + // Check for DeepSeek DSML marker - if detected, stop streaming this chunk + // The DSML format indicates the model is outputting internal function call + // formatting instead of using the proper tool calling API + if containsDeepSeekMarker(data.Text) { + // Don't append or stream this content + return + } + // Also check if the accumulated content already has the marker + // (in case it arrived in a previous chunk) + if containsDeepSeekMarker(contentBuilder.String()) { + return + } contentBuilder.WriteString(data.Text) // Forward to callback - send ContentData struct jsonData, _ := json.Marshal(ContentData{Text: data.Text}) @@ -388,10 +400,12 @@ func (a *AgenticLoop) executeWithTools(ctx context.Context, sessionID string, me } // Create assistant message + // Clean DeepSeek artifacts from the content before storing + cleanedContent := cleanDeepSeekArtifacts(contentBuilder.String()) assistantMsg := Message{ ID: uuid.New().String(), Role: "assistant", - Content: contentBuilder.String(), + Content: cleanedContent, ReasoningContent: thinkingBuilder.String(), Timestamp: time.Now(), } @@ -1225,7 +1239,7 @@ func (a *AgenticLoop) ensureFinalTextResponse( summaryMsg := Message{ ID: uuid.New().String(), Role: "assistant", - Content: summaryBuilder.String(), + Content: cleanDeepSeekArtifacts(summaryBuilder.String()), Timestamp: time.Now(), } resultMessages = append(resultMessages, summaryMsg) @@ -2226,3 +2240,49 @@ func formatKeyParams(input map[string]interface{}) string { return strings.Join(parts, ", ") } + +// cleanDeepSeekArtifacts removes DeepSeek's internal tool call format leakage. +// When DeepSeek doesn't properly use the function calling API, it may output +// its internal markup like <|DSML|function_calls>, <|DSML|invoke>, etc. +// These patterns can appear with Unicode pipe (|) or ASCII pipe (|). +// This is applied to chat responses to prevent the artifacts from being shown to users. +func cleanDeepSeekArtifacts(content string) string { + if content == "" { + return content + } + + // DeepSeek internal function call format markers + markers := []string{ + "<|DSML|", // Unicode pipe variant (opening) + "= 0 { + // DeepSeek function call blocks typically appear at the end of responses + // Remove everything from the marker to the end + content = strings.TrimSpace(content[:idx]) + } + } + + return content +} + +// containsDeepSeekMarker checks if the content contains any DeepSeek internal function call markers. +// This is used during streaming to detect when we should stop forwarding content. +func containsDeepSeekMarker(content string) bool { + markers := []string{ + "<|DSML|", // Unicode pipe variant + "<|DSML|", // ASCII pipe variant + } + for _, marker := range markers { + if strings.Contains(content, marker) { + return true + } + } + return false +} diff --git a/internal/ai/chat/service.go b/internal/ai/chat/service.go index eab8ace71..5cde3f73d 100644 --- a/internal/ai/chat/service.go +++ b/internal/ai/chat/service.go @@ -328,6 +328,12 @@ func (s *Service) ExecuteStream(ctx context.Context, req ExecuteRequest, callbac configuredModel = strings.TrimSpace(s.cfg.GetChatModel()) } s.mu.RUnlock() + + // Per-request autonomous mode override (used by investigation to avoid + // mutating shared service state from concurrent goroutines). + if req.AutonomousMode != nil { + autonomousMode = *req.AutonomousMode + } selectedModel = configuredModel if overrideModel != "" { selectedModel = overrideModel @@ -481,6 +487,11 @@ func (s *Service) ExecuteStream(ctx context.Context, req ExecuteRequest, callbac defer loop.SetMaxTurns(MaxAgenticTurns) } + // Apply per-request autonomous mode to the loop. For investigation requests + // with AutonomousMode set, this uses the per-request value instead of + // mutating shared service state from concurrent goroutines. + loop.SetAutonomousMode(autonomousMode) + resultMessages, err := loop.ExecuteWithTools(ctx, session.ID, messages, filteredTools, callback) log.Debug(). diff --git a/internal/ai/chat/types.go b/internal/ai/chat/types.go index db02fa1dc..9c4b84265 100644 --- a/internal/ai/chat/types.go +++ b/internal/ai/chat/types.go @@ -67,11 +67,12 @@ type StructuredMention struct { // ExecuteRequest represents a chat execution request type ExecuteRequest struct { - Prompt string `json:"prompt"` - SessionID string `json:"session_id,omitempty"` - Model string `json:"model,omitempty"` - Mentions []StructuredMention `json:"mentions,omitempty"` - MaxTurns int `json:"max_turns,omitempty"` // Override max agentic turns (0 = use default) + Prompt string `json:"prompt"` + SessionID string `json:"session_id,omitempty"` + Model string `json:"model,omitempty"` + Mentions []StructuredMention `json:"mentions,omitempty"` + MaxTurns int `json:"max_turns,omitempty"` // Override max agentic turns (0 = use default) + AutonomousMode *bool `json:"autonomous_mode,omitempty"` // Per-request autonomous override (nil = use service default) } // QuestionAnswer represents a user's answer to a question diff --git a/internal/ai/patrol_ai_test.go b/internal/ai/patrol_ai_test.go index 106aaa42a..8bff65050 100644 --- a/internal/ai/patrol_ai_test.go +++ b/internal/ai/patrol_ai_test.go @@ -8,7 +8,7 @@ import ( func TestCleanThinkingTokens_DeepSeek(t *testing.T) { input := "Some analysis\n<|end▁of▁thinking|>\nActual content here" - result := cleanThinkingTokens(input) + result := CleanThinkingTokens(input) if containsSubstr(result, "end▁of▁thinking") { t.Errorf("DeepSeek thinking marker should be removed, got: %s", result) } @@ -34,22 +34,22 @@ After comprehensive analysis of your infrastructure, I identified several issues 1. **Critical CPU overload on Tower host**` - result := cleanThinkingTokens(input) + result := CleanThinkingTokens(input) if containsSubstr(result, "<|end▁of▁thinking|>") { - t.Errorf("cleanThinkingTokens() should have removed DeepSeek thinking markers") + t.Errorf("CleanThinkingTokens() should have removed DeepSeek thinking markers") } if containsSubstr(result, "Now, also consider") || containsSubstr(result, "Let's add an info") { - t.Errorf("cleanThinkingTokens() should have removed internal reasoning") + t.Errorf("CleanThinkingTokens() should have removed internal reasoning") } if !containsSubstr(result, "## Analysis Summary") { - t.Errorf("cleanThinkingTokens() removed header") + t.Errorf("CleanThinkingTokens() removed header") } if !containsSubstr(result, "### Key Findings") { - t.Errorf("cleanThinkingTokens() removed findings section") + t.Errorf("CleanThinkingTokens() removed findings section") } if !containsSubstr(result, "Critical CPU overload") { - t.Errorf("cleanThinkingTokens() removed actual finding") + t.Errorf("CleanThinkingTokens() removed actual finding") } } @@ -60,10 +60,10 @@ Now, let's check something. ## Real Content` - result := cleanThinkingTokens(input) + result := CleanThinkingTokens(input) if result != "## Real Content" { - t.Errorf("cleanThinkingTokens() failed for ASCII variant: got %q", result) + t.Errorf("CleanThinkingTokens() failed for ASCII variant: got %q", result) } } @@ -71,7 +71,7 @@ Now, let's check something. func TestCleanThinkingTokens_ThinkBlock(t *testing.T) { input := "Before content\nThis is internal reasoning\nthat spans multiple lines\nAfter content" - result := cleanThinkingTokens(input) + result := CleanThinkingTokens(input) if containsSubstr(result, "internal reasoning") { t.Errorf(" block content should be removed, got: %s", result) } @@ -85,7 +85,7 @@ func TestCleanThinkingTokens_ThinkBlock(t *testing.T) { func TestCleanThinkingTokens_ThoughtBlock(t *testing.T) { input := "Start\nSome deep thought process here\nEnd" - result := cleanThinkingTokens(input) + result := CleanThinkingTokens(input) if containsSubstr(result, "deep thought") { t.Errorf(" block content should be removed, got: %s", result) } @@ -99,7 +99,7 @@ func TestCleanThinkingTokens_ThoughtBlock(t *testing.T) { func TestCleanThinkingTokens_ReasoningBlock(t *testing.T) { input := "Start\n<|reasoning|>Internal reasoning here<|/reasoning|>\nEnd" - result := cleanThinkingTokens(input) + result := CleanThinkingTokens(input) if containsSubstr(result, "Internal reasoning") { t.Errorf("<|reasoning|> block content should be removed, got: %s", result) } @@ -113,7 +113,7 @@ func TestCleanThinkingTokens_ReasoningBlock(t *testing.T) { func TestCleanThinkingTokens_CaseInsensitiveBlocks(t *testing.T) { input := "Before\nuppercase thinking\nAfter" - result := cleanThinkingTokens(input) + result := CleanThinkingTokens(input) if containsSubstr(result, "uppercase thinking") { t.Errorf("Case-insensitive block should be removed, got: %s", result) } @@ -124,7 +124,7 @@ func TestCleanThinkingTokens_CaseInsensitiveBlocks(t *testing.T) { func TestCleanThinkingTokens_MultipleBlocks(t *testing.T) { input := "first blockcontent betweensecond blockfinal content" - result := cleanThinkingTokens(input) + result := CleanThinkingTokens(input) if containsSubstr(result, "first block") || containsSubstr(result, "second block") { t.Errorf("All think blocks should be removed, got: %s", result) } @@ -135,7 +135,7 @@ func TestCleanThinkingTokens_MultipleBlocks(t *testing.T) { func TestCleanThinkingTokens_UnclosedBlock(t *testing.T) { input := "Start contentunclosed block with no end" - result := cleanThinkingTokens(input) + result := CleanThinkingTokens(input) if !containsSubstr(result, "Start content") { t.Errorf("Content before unclosed block should be preserved, got: %s", result) } @@ -157,14 +157,14 @@ Now, I need to look at memory. - Issue 1` - result := cleanThinkingTokens(input) + result := CleanThinkingTokens(input) if !containsSubstr(result, "## Analysis") || !containsSubstr(result, "### Findings") || !containsSubstr(result, "- Issue 1") { - t.Errorf("cleanThinkingTokens() removed too much: got %q", result) + t.Errorf("CleanThinkingTokens() removed too much: got %q", result) } if containsSubstr(result, "Let's check") || containsSubstr(result, "Now, I need") { - t.Errorf("cleanThinkingTokens() should have removed reasoning: got %q", result) + t.Errorf("CleanThinkingTokens() should have removed reasoning: got %q", result) } } @@ -180,14 +180,14 @@ This is a normal response without any thinking tokens. 1. Issue one 2. Issue two` - result := cleanThinkingTokens(input) + result := CleanThinkingTokens(input) if result != input { - t.Errorf("cleanThinkingTokens() modified clean content:\nGot: %q\nExpected: %q", result, input) + t.Errorf("CleanThinkingTokens() modified clean content:\nGot: %q\nExpected: %q", result, input) } } func TestCleanThinkingTokens_EmptyContent(t *testing.T) { - result := cleanThinkingTokens("") + result := CleanThinkingTokens("") if result != "" { t.Errorf("Empty string should return empty, got: %q", result) } @@ -203,3 +203,48 @@ func containsSubstr(s, substr string) bool { } return false } + +func TestCleanThinkingTokens_DeepSeekDSMLFormat(t *testing.T) { + // This test verifies that DeepSeek's internal function call format is removed. + // When DeepSeek doesn't properly use the function calling API, it outputs + // its internal markup like <|DSML|function_calls> into visible responses. + input := `I've successfully updated the services.yaml file to remove the InfluxDB entry. Let me verify the change: + +<|DSML|function_calls> +<|DSML|invoke name="pulse_read"> +<|DSML|parameter name="action" string="true">exec +<|DSML|parameter name="command" string="true">grep -A 10 "Monitoring:" /opt/homepage/config/services.yaml +<|DSML|parameter name="target_host" string="true">homepage-docker + +` + + result := CleanThinkingTokens(input) + + expected := "I've successfully updated the services.yaml file to remove the InfluxDB entry. Let me verify the change:" + if result != expected { + t.Errorf("CleanThinkingTokens() failed to remove DeepSeek DSML format:\nGot: %q\nExpected: %q", result, expected) + } + if containsSubstr(result, "DSML") { + t.Errorf("CleanThinkingTokens() should have removed all DSML markers, got: %s", result) + } + if containsSubstr(result, "pulse_read") { + t.Errorf("CleanThinkingTokens() should have removed tool call content, got: %s", result) + } +} + +func TestCleanThinkingTokens_DeepSeekDSMLFormatASCII(t *testing.T) { + // Test the ASCII variant of DSML markers + input := `Command completed successfully. + +<|DSML|function_calls> +<|DSML|invoke name="pulse_query"> +<|DSML|parameter name="action">health + +` + + result := CleanThinkingTokens(input) + expected := "Command completed successfully." + if result != expected { + t.Errorf("CleanThinkingTokens() failed to remove ASCII DSML format:\nGot: %q\nExpected: %q", result, expected) + } +}