From 71922502001bbc8fe29127af8a39cec5cb143a69 Mon Sep 17 00:00:00 2001 From: Ignacio Alonso Date: Tue, 5 Aug 2025 16:59:17 -0600 Subject: [PATCH] feat(lmstudio): Add Harmony format parser for GPT-OSS models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implement Harmony channel parser to extract analysis, final, and commentary - Parse tool calls from commentary channel to standard JSON format - Handle multiline markdown content in final channel responses - Remove GPT-OSS tool restrictions as Harmony parser enables full support - Add comprehensive tests for Harmony format parsing - Document the issue and solution in GPT-OSS-HARMONY-ISSUE.md This allows GPT-OSS models to work properly with tool calling through LM Studio by parsing their native Harmony output format. 🤖 Generated with Claude Code Co-Authored-By: Claude --- cmd/simple-agent/main.go | 51 ++++--- docs/GPT-OSS-HARMONY-ISSUE.md | 135 +++++++++++++++++++ llm/lmstudio/client.go | 52 ++++++- llm/lmstudio/harmony.go | 156 +++++++++++++++++++++ llm/lmstudio/harmony_test.go | 246 ++++++++++++++++++++++++++++++++++ 5 files changed, 622 insertions(+), 18 deletions(-) create mode 100644 docs/GPT-OSS-HARMONY-ISSUE.md create mode 100644 llm/lmstudio/harmony.go create mode 100644 llm/lmstudio/harmony_test.go diff --git a/cmd/simple-agent/main.go b/cmd/simple-agent/main.go index f76b6a7..36dbfb0 100644 --- a/cmd/simple-agent/main.go +++ b/cmd/simple-agent/main.go @@ -19,6 +19,7 @@ import ( "github.com/nachoal/simple-agent-go/llm/anthropic" "github.com/nachoal/simple-agent-go/llm/deepseek" "github.com/nachoal/simple-agent-go/llm/groq" + "github.com/nachoal/simple-agent-go/llm/llamacpp" "github.com/nachoal/simple-agent-go/llm/lmstudio" "github.com/nachoal/simple-agent-go/llm/moonshot" "github.com/nachoal/simple-agent-go/llm/ollama" @@ -158,7 +159,7 @@ func runTUI(cmd *cobra.Command, args []string) error { // Create all provider clients for model selection providers := make(map[string]llm.Client) - providerNames := []string{"openai", "anthropic", "moonshot", "deepseek", "perplexity", "groq", "lmstudio", "ollama"} + providerNames := []string{"openai", "anthropic", "moonshot", "deepseek", "perplexity", "groq", "lmstudio", "ollama", "llamacpp"} // Debug: count successful providers successCount := 0 @@ -185,10 +186,14 @@ func runTUI(cmd *cobra.Command, args []string) error { } // Create agent - agentInstance := agent.New(llmClient, - agent.WithMaxIterations(10), - agent.WithTemperature(0.7), - ) + var agentOpts []agent.Option + agentOpts = append(agentOpts, agent.WithMaxIterations(10)) + agentOpts = append(agentOpts, agent.WithTemperature(0.7)) + + // GPT-OSS models now supported with Harmony parser + // Tools are enabled automatically via the Harmony format parser in lmstudio/harmony.go + + agentInstance := agent.New(llmClient, agentOpts...) // Initialize history manager historyMgr, err := history.NewManager() @@ -228,10 +233,12 @@ func runTUI(cmd *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("failed to create %s client: %w", provider, err) } - agentInstance = agent.New(llmClient, - agent.WithMaxIterations(10), - agent.WithTemperature(0.7), - ) + // Recreate agent with proper options + var opts []agent.Option + opts = append(opts, agent.WithMaxIterations(10)) + opts = append(opts, agent.WithTemperature(0.7)) + // GPT-OSS models now supported with Harmony parser + agentInstance = agent.New(llmClient, opts...) } } } else if resumeSet { @@ -314,10 +321,12 @@ func runTUI(cmd *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("failed to create %s client: %w", provider, err) } - agentInstance = agent.New(llmClient, - agent.WithMaxIterations(10), - agent.WithTemperature(0.7), - ) + // Recreate agent with proper options + var opts []agent.Option + opts = append(opts, agent.WithMaxIterations(10)) + opts = append(opts, agent.WithTemperature(0.7)) + // GPT-OSS models now supported with Harmony parser + agentInstance = agent.New(llmClient, opts...) } } else { // Start new session @@ -399,10 +408,14 @@ func runQuery(cmd *cobra.Command, args []string) error { defer llmClient.Close() // Create agent - agentInstance := agent.New(llmClient, - agent.WithMaxIterations(10), - agent.WithTemperature(0.7), - ) + var agentOpts []agent.Option + agentOpts = append(agentOpts, agent.WithMaxIterations(10)) + agentOpts = append(agentOpts, agent.WithTemperature(0.7)) + + // GPT-OSS models now supported with Harmony parser + // Tools are enabled automatically via the Harmony format parser in lmstudio/harmony.go + + agentInstance := agent.New(llmClient, agentOpts...) // If verbose, show the enhanced system prompt (including tools) if verbose { @@ -507,6 +520,9 @@ func createLLMClient(provider, model string) (llm.Client, error) { case "ollama": return ollama.NewClient(llm.WithModel(model)) + case "llamacpp", "llama.cpp", "llama-cpp": + return llamacpp.NewClient(llm.WithModel(model)) + default: return nil, fmt.Errorf("unknown provider: %s", provider) } @@ -522,6 +538,7 @@ func getDefaultModel(provider string) string { "groq": "mixtral-8x7b-32768", "lmstudio": "local-model", "ollama": "llama2", + "llamacpp": "gpt-oss-120b", } if model, ok := defaults[strings.ToLower(provider)]; ok { diff --git a/docs/GPT-OSS-HARMONY-ISSUE.md b/docs/GPT-OSS-HARMONY-ISSUE.md new file mode 100644 index 0000000..878e344 --- /dev/null +++ b/docs/GPT-OSS-HARMONY-ISSUE.md @@ -0,0 +1,135 @@ +# GPT-OSS Harmony Format Issue & Solution + +## The Problem + +GPT-OSS models output responses in OpenAI's Harmony format, which contains channel tags that break llama.cpp's chat parser: + +``` +<|channel|>analysis<|message|>Thinking about the problem...<|start|>assistant<|channel|>final<|message|>Here's the answer +``` + +When llama.cpp encounters `<|start|>assistant` within a response, it crashes with: +``` +libc++abi: terminating due to uncaught exception of type std::runtime_error: Unexpected content at end of input +``` + +## Current Status + +1. **llama.cpp PR #15091** adds GPT-OSS support but has known template parsing issues +2. **Community workarounds** (no `--jinja` flag) produce raw Harmony output, unusable for chat UIs +3. **"Fixed" templates** only fix input formatting, not output parsing +4. **Server crashes** in TUI mode when GPT-OSS outputs Harmony format + +## Proposed Solution: Harmony Parser in simple-agent + +Add a Harmony format parser to simple-agent-go that: + +1. **Detects GPT-OSS models** (when provider is "llamacpp" and model contains "gpt-oss") +2. **Intercepts responses** before standard parsing +3. **Extracts channels**: + - `<|channel|>analysis<|message|>` → Reasoning/thinking content + - `<|channel|>final<|message|>` → User-visible response + - `<|channel|>commentary<|message|>` → Tool calls +4. **Converts to standard format** that simple-agent expects + +## Implementation Steps + +### 1. Create Harmony Parser (`llm/llamacpp/harmony.go`) +```go +package llamacpp + +import ( + "regexp" + "strings" +) + +type HarmonyResponse struct { + Analysis string + Final string + Commentary string + ToolCalls []ToolCall +} + +func ParseHarmonyFormat(content string) (*HarmonyResponse, error) { + // Extract channel contents with regex + analysisRe := regexp.MustCompile(`<\|channel\|>analysis<\|message\|>(.*?)(?:<\|start\|>|<\|end\|>|$)`) + finalRe := regexp.MustCompile(`<\|channel\|>final<\|message\|>(.*?)(?:<\|return\|>|<\|end\|>|$)`) + commentaryRe := regexp.MustCompile(`<\|channel\|>commentary.*?<\|message\|>(.*?)(?:<\|call\|>|<\|end\|>|$)`) + + // Parse and return structured response +} + +func ConvertToStandardResponse(harmony *HarmonyResponse) string { + // Return clean final content for display + return harmony.Final +} +``` + +### 2. Modify llamacpp Client (`llm/llamacpp/client.go`) + +In the `Chat` method, add Harmony detection: +```go +func (c *Client) Chat(ctx context.Context, request *llm.ChatRequest) (*llm.ChatResponse, error) { + // ... existing code ... + + // After getting response + if strings.Contains(c.model, "gpt-oss") && strings.Contains(chatResp.Choices[0].Message.Content, "<|channel|>") { + harmony, err := ParseHarmonyFormat(chatResp.Choices[0].Message.Content) + if err == nil { + // Convert Harmony format to standard response + chatResp.Choices[0].Message.Content = harmony.Final + // Store reasoning if field exists + // Convert commentary to tool calls if present + } + } + + return &chatResp, nil +} +``` + +### 3. Enable Tools for GPT-OSS + +Once Harmony parsing works, re-enable tools in `cmd/simple-agent/main.go`: +```go +// Remove the tool disable for GPT-OSS +// The Harmony parser will handle tool calls from commentary channel +``` + +## Testing + +1. Start server without `--jinja` to get raw Harmony output: +```bash +~/bin/llama-cpp-latest/llama-server \ + --model ~/.cache/lm-studio/models/ggml-org/gpt-oss-120b-GGUF/gpt-oss-120b-mxfp4-00001-of-00003.gguf \ + --host 0.0.0.0 --port 8080 -c 8192 -fa +``` + +2. Test with simple-agent: +```bash +./simple-agent --provider llamacpp --model gpt-oss-120b +``` + +## Files to Modify + +1. `/Users/ia/code/projects/simple-agents/simple-agent-go/llm/llamacpp/harmony.go` (NEW) +2. `/Users/ia/code/projects/simple-agents/simple-agent-go/llm/llamacpp/client.go` (MODIFY Chat/ChatStream) +3. `/Users/ia/code/projects/simple-agents/simple-agent-go/cmd/simple-agent/main.go` (RE-ENABLE tools) + +## Expected Outcome + +- GPT-OSS works in TUI mode without crashes +- Clean responses without visible Harmony tags +- Tool calling support through commentary channel parsing +- Reasoning available in separate field + +## Alternative Approaches + +1. **Wait for llama.cpp fix** - PR #15091 discussion suggests ongoing work +2. **Use different model** - Other models work fine with current setup +3. **Post-process in TUI** - Less clean but isolates changes to UI layer + +## References + +- llama.cpp PR #15091: https://github.com/ggml-org/llama.cpp/pull/15091 +- OpenAI Harmony: https://github.com/openai/harmony +- Fixed template attempt: https://huggingface.co/DevQuasar/openai.gpt-oss-20b-GGUF/resolve/main/gpt-oss_fixed.jinja \ No newline at end of file diff --git a/llm/lmstudio/client.go b/llm/lmstudio/client.go index 285e4fc..d6c89c5 100644 --- a/llm/lmstudio/client.go +++ b/llm/lmstudio/client.go @@ -153,10 +153,60 @@ func (c *Client) Chat(ctx context.Context, request *llm.ChatRequest) (*llm.ChatR return nil, fmt.Errorf("failed to parse response: %w", err) } + // Check if this is a GPT-OSS model with Harmony format + if len(response.Choices) > 0 && response.Choices[0].Message.Content != nil { + content := *response.Choices[0].Message.Content + + // Detect GPT-OSS model and Harmony format + isGPTOSS := strings.Contains(strings.ToLower(request.Model), "gpt-oss") || + strings.Contains(strings.ToLower(request.Model), "gpt_oss") + + if isGPTOSS && IsHarmonyFormat(content) { + if os.Getenv("SIMPLE_AGENT_DEBUG") == "true" { + fmt.Fprintf(os.Stderr, "[LM Studio] Detected Harmony format in GPT-OSS response\n") + fmt.Fprintf(os.Stderr, "[LM Studio] Raw content: %s\n", content) + } + + // Parse Harmony format + harmony, err := ParseHarmonyFormat(content) + if err == nil { + // Replace content with clean final message + cleanContent := ConvertToStandardResponse(harmony) + // Only set content if there's actual final content, otherwise set to nil for tool calls + if cleanContent != "" || len(harmony.ToolCalls) == 0 { + response.Choices[0].Message.Content = &cleanContent + } else { + // Set content to nil when there are only tool calls (no final message) + response.Choices[0].Message.Content = nil + } + + // Add tool calls if present + if len(harmony.ToolCalls) > 0 { + response.Choices[0].Message.ToolCalls = harmony.ToolCalls + + if os.Getenv("SIMPLE_AGENT_DEBUG") == "true" { + fmt.Fprintf(os.Stderr, "[LM Studio] Extracted %d tool calls from Harmony format\n", len(harmony.ToolCalls)) + for i, tc := range harmony.ToolCalls { + fmt.Fprintf(os.Stderr, "[LM Studio] Tool Call %d: %s with args: %s\n", + i, tc.Function.Name, string(tc.Function.Arguments)) + } + } + } + + // Store analysis/reasoning if needed (could be added to a custom field later) + if os.Getenv("SIMPLE_AGENT_DEBUG") == "true" && harmony.Analysis != "" { + fmt.Fprintf(os.Stderr, "[LM Studio] Analysis channel: %s\n", harmony.Analysis) + } + } else if os.Getenv("SIMPLE_AGENT_DEBUG") == "true" { + fmt.Fprintf(os.Stderr, "[LM Studio] Failed to parse Harmony format: %v\n", err) + } + } + } + // Debug log parsed response if os.Getenv("SIMPLE_AGENT_DEBUG") == "true" { if len(response.Choices) > 0 && len(response.Choices[0].Message.ToolCalls) > 0 { - fmt.Fprintf(os.Stderr, "[LM Studio] Parsed %d tool calls\n", len(response.Choices[0].Message.ToolCalls)) + fmt.Fprintf(os.Stderr, "[LM Studio] Final parsed %d tool calls\n", len(response.Choices[0].Message.ToolCalls)) for i, tc := range response.Choices[0].Message.ToolCalls { fmt.Fprintf(os.Stderr, "[LM Studio] Tool Call %d: %s with args: %s\n", i, tc.Function.Name, string(tc.Function.Arguments)) } diff --git a/llm/lmstudio/harmony.go b/llm/lmstudio/harmony.go new file mode 100644 index 0000000..ed50c7e --- /dev/null +++ b/llm/lmstudio/harmony.go @@ -0,0 +1,156 @@ +package lmstudio + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" + + "github.com/nachoal/simple-agent-go/llm" +) + +// HarmonyResponse represents the parsed Harmony format response +type HarmonyResponse struct { + Analysis string // Content from analysis channel + Final string // Content from final channel (user-visible) + Commentary string // Content from commentary channel (tool calls) + ToolCalls []llm.ToolCall // Parsed tool calls +} + +// ParseHarmonyFormat parses GPT-OSS Harmony format output +func ParseHarmonyFormat(content string) (*HarmonyResponse, error) { + response := &HarmonyResponse{} + + // Extract analysis channel (make the regex more greedy and handle multiline) + analysisRe := regexp.MustCompile(`(?s)<\|channel\|>analysis<\|message\|>(.*?)(?:<\|end\|>|<\|start\|>|$)`) + if matches := analysisRe.FindStringSubmatch(content); len(matches) > 1 { + response.Analysis = strings.TrimSpace(matches[1]) + } + + // Extract final channel (user-visible content) + // Use (?s) flag to make . match newlines for multiline content + finalRe := regexp.MustCompile(`(?s)<\|channel\|>final<\|message\|>(.*?)(?:<\|return\|>|<\|end\|>|$)`) + if matches := finalRe.FindStringSubmatch(content); len(matches) > 1 { + response.Final = strings.TrimSpace(matches[1]) + } + + // Extract commentary channel (tool calls) + // Format: <|channel|>commentary to=functions.tool_name <|constrain|>json<|message|>{"args": "value"} + commentaryRe := regexp.MustCompile(`(?s)<\|channel\|>commentary\s+to=functions\.(\w+).*?<\|message\|>(.*?)(?:<\|call\|>|<\|end\|>|$)`) + if matches := commentaryRe.FindAllStringSubmatch(content, -1); len(matches) > 0 { + for _, match := range matches { + if len(match) > 2 { + toolName := match[1] + argsJSON := strings.TrimSpace(match[2]) + response.Commentary = fmt.Sprintf("Tool: %s, Args: %s", toolName, argsJSON) + + // Parse tool call + toolCall := llm.ToolCall{ + ID: fmt.Sprintf("harmony_%s_%d", toolName, len(response.ToolCalls)), + Type: "function", + Function: llm.FunctionCall{ + Name: toolName, + }, + } + + // Convert the arguments to the expected format + // GPT-OSS might send {"input": "value"} or other formats + if argsJSON != "" { + // Try to parse and re-encode to ensure valid JSON + var parsedArgs map[string]interface{} + if err := json.Unmarshal([]byte(argsJSON), &parsedArgs); err == nil { + if reencoded, err := json.Marshal(parsedArgs); err == nil { + toolCall.Function.Arguments = json.RawMessage(reencoded) + } else { + // If re-encoding fails, use the original + toolCall.Function.Arguments = json.RawMessage(argsJSON) + } + } else { + // If parsing fails, try to use as-is (might be valid JSON we can't parse) + toolCall.Function.Arguments = json.RawMessage(argsJSON) + } + } + + response.ToolCalls = append(response.ToolCalls, toolCall) + } + } + } + + // If no explicit final channel but we have content without tags, use it as final + if response.Final == "" && !strings.Contains(content, "<|channel|>") { + response.Final = strings.TrimSpace(content) + } + + return response, nil +} + +// ConvertToStandardResponse converts Harmony response to standard format +func ConvertToStandardResponse(harmony *HarmonyResponse) string { + // Return the final content for display + if harmony.Final != "" { + return harmony.Final + } + // Don't return analysis content as it's internal reasoning + // Return empty string if no final content + return "" +} + +// IsHarmonyFormat checks if the content contains Harmony format markers +func IsHarmonyFormat(content string) bool { + return strings.Contains(content, "<|channel|>") || + strings.Contains(content, "<|message|>") || + strings.Contains(content, "<|end|>") || + strings.Contains(content, "<|start|>") +} + +// ExtractToolCallsFromCommentary extracts tool calls from commentary channel +// This handles various formats that GPT-OSS might use +func ExtractToolCallsFromCommentary(content string) []llm.ToolCall { + var toolCalls []llm.ToolCall + + // Pattern 1: <|channel|>commentary to=functions.tool_name <|constrain|>json<|message|>{...} + pattern1 := regexp.MustCompile(`to=functions\.(\w+).*?<\|message\|>\s*(\{[^}]+\})`) + + // Pattern 2: Alternative format that might appear + pattern2 := regexp.MustCompile(`"name":\s*"(\w+)".*?"arguments":\s*(\{[^}]+\})`) + + // Try pattern 1 first + if matches := pattern1.FindAllStringSubmatch(content, -1); len(matches) > 0 { + for i, match := range matches { + if len(match) > 2 { + toolName := match[1] + argsJSON := match[2] + + toolCall := llm.ToolCall{ + ID: fmt.Sprintf("harmony_call_%d", i), + Type: "function", + Function: llm.FunctionCall{ + Name: toolName, + Arguments: json.RawMessage(argsJSON), + }, + } + toolCalls = append(toolCalls, toolCall) + } + } + } else if matches := pattern2.FindAllStringSubmatch(content, -1); len(matches) > 0 { + // Fallback to pattern 2 + for i, match := range matches { + if len(match) > 2 { + toolName := match[1] + argsJSON := match[2] + + toolCall := llm.ToolCall{ + ID: fmt.Sprintf("harmony_call_%d", i), + Type: "function", + Function: llm.FunctionCall{ + Name: toolName, + Arguments: json.RawMessage(argsJSON), + }, + } + toolCalls = append(toolCalls, toolCall) + } + } + } + + return toolCalls +} \ No newline at end of file diff --git a/llm/lmstudio/harmony_test.go b/llm/lmstudio/harmony_test.go new file mode 100644 index 0000000..e11764f --- /dev/null +++ b/llm/lmstudio/harmony_test.go @@ -0,0 +1,246 @@ +package lmstudio + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestParseHarmonyFormat(t *testing.T) { + tests := []struct { + name string + input string + wantFinal string + wantAnalysis string + wantToolName string + wantToolArgs string + }{ + { + name: "Simple greeting response", + input: `<|channel|>analysis<|message|>The user says "hi". Simple greeting. Respond accordingly.<|end|><|start|>assistant<|channel|>final<|message|>Hello! How can I assist you today?`, + wantFinal: "Hello! How can I assist you today?", + wantAnalysis: "The user says \"hi\". Simple greeting. Respond accordingly.", + wantToolName: "", + wantToolArgs: "", + }, + { + name: "Wikipedia tool call", + input: `<|channel|>analysis<|message|>We need to perform Wikipedia search. Let's call wikipedia first.<|end|><|start|>assistant<|channel|>commentary to=functions.wikipedia <|constrain|>json<|message|>{"input":"Tunguska incident"}`, + wantFinal: "", + wantAnalysis: "We need to perform Wikipedia search. Let's call wikipedia first.", + wantToolName: "wikipedia", + wantToolArgs: `{"input":"Tunguska incident"}`, + }, + { + name: "Google search tool call", + input: `<|channel|>commentary to=functions.google_search <|constrain|>json<|message|>{"query":"latest AI news"}`, + wantFinal: "", + wantAnalysis: "", + wantToolName: "google_search", + wantToolArgs: `{"query":"latest AI news"}`, + }, + { + name: "Multiple channels", + input: `<|channel|>analysis<|message|>Thinking about the problem...<|end|><|channel|>final<|message|>Here's the answer<|end|>`, + wantFinal: "Here's the answer", + wantAnalysis: "Thinking about the problem...", + wantToolName: "", + wantToolArgs: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ParseHarmonyFormat(tt.input) + if err != nil { + t.Fatalf("ParseHarmonyFormat() error = %v", err) + } + + // Check final content + if result.Final != tt.wantFinal { + t.Errorf("Final = %q, want %q", result.Final, tt.wantFinal) + } + + // Check analysis content + if result.Analysis != tt.wantAnalysis { + t.Errorf("Analysis = %q, want %q", result.Analysis, tt.wantAnalysis) + } + + // Check tool calls + if tt.wantToolName != "" { + if len(result.ToolCalls) == 0 { + t.Errorf("Expected tool call but got none") + } else { + if result.ToolCalls[0].Function.Name != tt.wantToolName { + t.Errorf("Tool name = %q, want %q", result.ToolCalls[0].Function.Name, tt.wantToolName) + } + + // Compare JSON arguments (normalize whitespace) + var gotArgs, wantArgs map[string]interface{} + json.Unmarshal(result.ToolCalls[0].Function.Arguments, &gotArgs) + json.Unmarshal([]byte(tt.wantToolArgs), &wantArgs) + + gotJSON, _ := json.Marshal(gotArgs) + wantJSON, _ := json.Marshal(wantArgs) + + if string(gotJSON) != string(wantJSON) { + t.Errorf("Tool args = %s, want %s", string(gotJSON), string(wantJSON)) + } + } + } else if len(result.ToolCalls) > 0 { + t.Errorf("Expected no tool calls but got %d", len(result.ToolCalls)) + } + }) + } +} + +func TestIsHarmonyFormat(t *testing.T) { + tests := []struct { + name string + input string + want bool + }{ + { + name: "Harmony format with channels", + input: `<|channel|>analysis<|message|>Some content`, + want: true, + }, + { + name: "Harmony format with end tag", + input: `Some content<|end|>`, + want: true, + }, + { + name: "Regular text", + input: `This is just regular text without harmony tags`, + want: false, + }, + { + name: "JSON content", + input: `{"name": "tool", "arguments": {}}`, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsHarmonyFormat(tt.input); got != tt.want { + t.Errorf("IsHarmonyFormat() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestConvertToStandardResponse(t *testing.T) { + tests := []struct { + name string + harmony *HarmonyResponse + expected string + }{ + { + name: "Final content present", + harmony: &HarmonyResponse{ + Final: "This is the final response", + Analysis: "This is analysis", + }, + expected: "This is the final response", + }, + { + name: "Only analysis present", + harmony: &HarmonyResponse{ + Final: "", + Analysis: "This is only analysis", + }, + expected: "", // Analysis is internal reasoning, not returned as content + }, + { + name: "Empty response", + harmony: &HarmonyResponse{ + Final: "", + Analysis: "", + }, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ConvertToStandardResponse(tt.harmony) + if result != tt.expected { + t.Errorf("ConvertToStandardResponse() = %q, want %q", result, tt.expected) + } + }) + } +} + +func TestMultilineFinalContent(t *testing.T) { + // Test that multiline markdown content in final channel is properly extracted + input := `<|channel|>analysis<|message|>Processing the request...<|end|><|start|>assistant<|channel|>final<|message|>## Tunguska Incident Report + +| Date | Location | Energy | +|------|----------|--------| +| June 30, 1908 | Siberia | 10-15 MT | + +The event was caused by an **asteroid airburst** at 5-10km altitude. + +### Key Evidence: +- No impact crater +- Tree-fall pattern +- Eyewitness accounts<|end|>` + + result, err := ParseHarmonyFormat(input) + if err != nil { + t.Fatalf("Failed to parse multiline content: %v", err) + } + + // Check that we got the full multiline content + if !strings.Contains(result.Final, "## Tunguska Incident Report") { + t.Error("Missing markdown header") + } + if !strings.Contains(result.Final, "| June 30, 1908 | Siberia | 10-15 MT |") { + t.Error("Missing table content") + } + if !strings.Contains(result.Final, "### Key Evidence:") { + t.Error("Missing subheader") + } + if !strings.Contains(result.Final, "- No impact crater") { + t.Error("Missing bullet points") + } +} + +func TestRealWorldExample(t *testing.T) { + // Test with the actual example from the user + input := `<|channel|>analysis<|message|>We need to perform Wikipedia search and Google search. Then synthesize info and provide a report on what really happened (the Tunguska event). We'll use wikipedia tool for snippet and google_search for results. Then combine. + +Let's call wikipedia first.<|end|><|start|>assistant<|channel|>commentary to=functions.wikipedia <|constrain|>json<|message|>{"input":"Tunguska incident"}` + + result, err := ParseHarmonyFormat(input) + if err != nil { + t.Fatalf("Failed to parse real-world example: %v", err) + } + + // Check that we extracted the analysis + if !strings.Contains(result.Analysis, "Wikipedia search and Google search") { + t.Errorf("Analysis doesn't contain expected content: %q", result.Analysis) + } + + // Check that we extracted the tool call + if len(result.ToolCalls) != 1 { + t.Fatalf("Expected 1 tool call, got %d", len(result.ToolCalls)) + } + + if result.ToolCalls[0].Function.Name != "wikipedia" { + t.Errorf("Expected wikipedia tool, got %q", result.ToolCalls[0].Function.Name) + } + + // Check the arguments + var args map[string]interface{} + err = json.Unmarshal(result.ToolCalls[0].Function.Arguments, &args) + if err != nil { + t.Fatalf("Failed to unmarshal tool arguments: %v", err) + } + + if args["input"] != "Tunguska incident" { + t.Errorf("Expected input 'Tunguska incident', got %q", args["input"]) + } +} \ No newline at end of file