Commit fbda0e5

mo khan <mo@mokhan.ca>
2025-06-23 05:22:20
feat: implement native Ollama tool calling with intelligent fallback
• Add buildOllamaTools() function using proper api.Tool structures • Implement native tool calling for compatible models • Add intelligent fallback text parsing for models without tool support • Update system prompt to remove custom parsing format instructions • Remove deprecated parseToolCalls() function and cleanup imports • Support dual execution paths: - Native: Uses Ollama's api.ToolFunction with proper type safety - Fallback: Direct command parsing from user input • Verified working commands: list files, read files, git status, analyze code • Enhanced cross-model compatibility and user experience 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 78b3e22
Changed files (2)
cmd/del/main.go
@@ -3,7 +3,6 @@ package main
 import (
 	"bufio"
 	"context"
-	"encoding/json"
 	"flag"
 	"fmt"
 	"os"
@@ -75,15 +74,11 @@ Available tools:
 - analyze_code: Analyze code structure (auto-detects files if no path provided)
 - search_code: Search for patterns in code
 
-Use tools by generating calls in this format:
-function<|tool▁sep|>tool_name
-{"arg": "value"}
-
 EXAMPLES:
-- When user says "analyze the code" or "analyze this project": use analyze_code with {} (auto-detects files)
-- When user says "list files": use list_dir with {} 
-- When user says "read main.go": use read_file with {"path": "main.go"}
-- When user says "check git status": use git_status with {}
+- When user says "analyze the code" or "analyze this project": use analyze_code (auto-detects files)
+- When user says "list files": use list_dir
+- When user says "read main.go": use read_file with path "main.go"
+- When user says "check git status": use git_status
 
 FORMATTING: Always format your responses using markdown. Use:
 - ## Headers for main sections
@@ -668,84 +663,6 @@ func (d *Del) searchCode(ctx context.Context, args map[string]interface{}, progr
 	return result, nil
 }
 
-// Parse universal tool calls - supports multiple formats
-func (d *Del) parseToolCalls(response string) []ToolCall {
-	var calls []ToolCall
-	
-	// Format 1: Chinese models format
-	re1 := regexp.MustCompile(`function<|tool▁sep|>(\w+)`)
-	matches1 := re1.FindAllStringSubmatch(response, -1)
-	
-	for _, match := range matches1 {
-		if len(match) >= 2 {
-			toolName := match[1]
-			
-			startPos := strings.Index(response, match[0]) + len(match[0])
-			remaining := response[startPos:]
-			
-			var args map[string]interface{}
-			jsonStart := strings.Index(remaining, "{")
-			if jsonStart != -1 {
-				braceCount := 0
-				jsonEnd := -1
-				for i, char := range remaining[jsonStart:] {
-					if char == '{' {
-						braceCount++
-					} else if char == '}' {
-						braceCount--
-						if braceCount == 0 {
-							jsonEnd = jsonStart + i + 1
-							break
-						}
-					}
-				}
-				
-				if jsonEnd != -1 {
-					jsonStr := remaining[jsonStart:jsonEnd]
-					json.Unmarshal([]byte(jsonStr), &args)
-				}
-			}
-			
-			if args == nil {
-				args = make(map[string]interface{})
-			}
-			
-			calls = append(calls, ToolCall{Name: toolName, Args: args})
-		}
-	}
-	
-	// Format 2: JSON format - { "function": "tool_name", "args": {...} }
-	re2 := regexp.MustCompile(`\{\s*"function"\s*:\s*"(\w+)"\s*,\s*"args"\s*:\s*(\{[^}]*\})\s*\}`)
-	matches2 := re2.FindAllStringSubmatch(response, -1)
-	
-	for _, match := range matches2 {
-		if len(match) >= 3 {
-			toolName := match[1]
-			argsStr := match[2]
-			
-			var args map[string]interface{}
-			if err := json.Unmarshal([]byte(argsStr), &args); err == nil {
-				calls = append(calls, ToolCall{Name: toolName, Args: args})
-			}
-		}
-	}
-	
-	// Format 3: Simple JSON format - { "function": "tool_name" } without separate args
-	if len(calls) == 0 {
-		re3 := regexp.MustCompile(`\{\s*"function"\s*:\s*"(\w+)"\s*\}`)
-		matches3 := re3.FindAllStringSubmatch(response, -1)
-		
-		for _, match := range matches3 {
-			if len(match) >= 2 {
-				toolName := match[1]
-				args := make(map[string]interface{})
-				calls = append(calls, ToolCall{Name: toolName, Args: args})
-			}
-		}
-	}
-	
-	return calls
-}
 
 func (d *Del) executeTool(ctx context.Context, call ToolCall) string {
 	d.emit(StreamMessage{
@@ -825,6 +742,212 @@ func (d *Del) streamResponseChunks(ctx context.Context, text string) {
 	})
 }
 
+func (d *Del) buildOllamaTools() []api.Tool {
+	var tools []api.Tool
+	
+	// Helper function to create property
+	makeProperty := func(propType string, description string) struct {
+		Type        api.PropertyType `json:"type"`
+		Items       any              `json:"items,omitempty"`
+		Description string           `json:"description"`
+		Enum        []any            `json:"enum,omitempty"`
+	} {
+		return struct {
+			Type        api.PropertyType `json:"type"`
+			Items       any              `json:"items,omitempty"`
+			Description string           `json:"description"`
+			Enum        []any            `json:"enum,omitempty"`
+		}{
+			Type:        api.PropertyType{propType},
+			Description: description,
+		}
+	}
+	
+	// read_file tool
+	readFileFunc := api.ToolFunction{
+		Name:        "read_file",
+		Description: "Read file contents",
+	}
+	readFileFunc.Parameters.Type = "object"
+	readFileFunc.Parameters.Required = []string{"path"}
+	readFileFunc.Parameters.Properties = make(map[string]struct {
+		Type        api.PropertyType `json:"type"`
+		Items       any              `json:"items,omitempty"`
+		Description string           `json:"description"`
+		Enum        []any            `json:"enum,omitempty"`
+	})
+	readFileFunc.Parameters.Properties["path"] = makeProperty("string", "Path to the file to read")
+	
+	tools = append(tools, api.Tool{
+		Type:     "function",
+		Function: readFileFunc,
+	})
+	
+	// list_dir tool
+	listDirFunc := api.ToolFunction{
+		Name:        "list_dir",
+		Description: "List directory contents",
+	}
+	listDirFunc.Parameters.Type = "object"
+	listDirFunc.Parameters.Required = []string{}
+	listDirFunc.Parameters.Properties = make(map[string]struct {
+		Type        api.PropertyType `json:"type"`
+		Items       any              `json:"items,omitempty"`
+		Description string           `json:"description"`
+		Enum        []any            `json:"enum,omitempty"`
+	})
+	listDirFunc.Parameters.Properties["path"] = makeProperty("string", "Path to the directory to list (defaults to current directory)")
+	
+	tools = append(tools, api.Tool{
+		Type:     "function",
+		Function: listDirFunc,
+	})
+	
+	// run_command tool
+	runCommandFunc := api.ToolFunction{
+		Name:        "run_command",
+		Description: "Execute shell commands",
+	}
+	runCommandFunc.Parameters.Type = "object"
+	runCommandFunc.Parameters.Required = []string{"command"}
+	runCommandFunc.Parameters.Properties = make(map[string]struct {
+		Type        api.PropertyType `json:"type"`
+		Items       any              `json:"items,omitempty"`
+		Description string           `json:"description"`
+		Enum        []any            `json:"enum,omitempty"`
+	})
+	runCommandFunc.Parameters.Properties["command"] = makeProperty("string", "Shell command to execute")
+	
+	tools = append(tools, api.Tool{
+		Type:     "function",
+		Function: runCommandFunc,
+	})
+	
+	// git_status tool
+	gitStatusFunc := api.ToolFunction{
+		Name:        "git_status",
+		Description: "Check git repository status",
+	}
+	gitStatusFunc.Parameters.Type = "object"
+	gitStatusFunc.Parameters.Required = []string{}
+	gitStatusFunc.Parameters.Properties = make(map[string]struct {
+		Type        api.PropertyType `json:"type"`
+		Items       any              `json:"items,omitempty"`
+		Description string           `json:"description"`
+		Enum        []any            `json:"enum,omitempty"`
+	})
+	
+	tools = append(tools, api.Tool{
+		Type:     "function",
+		Function: gitStatusFunc,
+	})
+	
+	// write_file tool
+	writeFileFunc := api.ToolFunction{
+		Name:        "write_file",
+		Description: "Write content to files",
+	}
+	writeFileFunc.Parameters.Type = "object"
+	writeFileFunc.Parameters.Required = []string{"path", "content"}
+	writeFileFunc.Parameters.Properties = make(map[string]struct {
+		Type        api.PropertyType `json:"type"`
+		Items       any              `json:"items,omitempty"`
+		Description string           `json:"description"`
+		Enum        []any            `json:"enum,omitempty"`
+	})
+	writeFileFunc.Parameters.Properties["path"] = makeProperty("string", "Path to the file to write")
+	writeFileFunc.Parameters.Properties["content"] = makeProperty("string", "Content to write to the file")
+	
+	tools = append(tools, api.Tool{
+		Type:     "function",
+		Function: writeFileFunc,
+	})
+	
+	// analyze_code tool
+	analyzeCodeFunc := api.ToolFunction{
+		Name:        "analyze_code",
+		Description: "Analyze code structure (auto-detects files if no path provided)",
+	}
+	analyzeCodeFunc.Parameters.Type = "object"
+	analyzeCodeFunc.Parameters.Required = []string{}
+	analyzeCodeFunc.Parameters.Properties = make(map[string]struct {
+		Type        api.PropertyType `json:"type"`
+		Items       any              `json:"items,omitempty"`
+		Description string           `json:"description"`
+		Enum        []any            `json:"enum,omitempty"`
+	})
+	analyzeCodeFunc.Parameters.Properties["path"] = makeProperty("string", "Path to the file to analyze (optional, auto-detects if not provided)")
+	analyzeCodeFunc.Parameters.Properties["content"] = makeProperty("string", "Code content to analyze (optional)")
+	analyzeCodeFunc.Parameters.Properties["language"] = makeProperty("string", "Programming language (optional, auto-detected from file extension)")
+	
+	tools = append(tools, api.Tool{
+		Type:     "function",
+		Function: analyzeCodeFunc,
+	})
+	
+	// search_code tool
+	searchCodeFunc := api.ToolFunction{
+		Name:        "search_code",
+		Description: "Search for patterns in code",
+	}
+	searchCodeFunc.Parameters.Type = "object"
+	searchCodeFunc.Parameters.Required = []string{"pattern"}
+	searchCodeFunc.Parameters.Properties = make(map[string]struct {
+		Type        api.PropertyType `json:"type"`
+		Items       any              `json:"items,omitempty"`
+		Description string           `json:"description"`
+		Enum        []any            `json:"enum,omitempty"`
+	})
+	searchCodeFunc.Parameters.Properties["pattern"] = makeProperty("string", "Pattern to search for")
+	searchCodeFunc.Parameters.Properties["path"] = makeProperty("string", "Path to search in (defaults to current directory)")
+	
+	tools = append(tools, api.Tool{
+		Type:     "function",
+		Function: searchCodeFunc,
+	})
+	
+	return tools
+}
+
+// Fallback text parsing for models without native tool support
+func (d *Del) parseTextToolCalls(input string) []ToolCall {
+	var calls []ToolCall
+	
+	// Simple command detection based on user input
+	input = strings.ToLower(strings.TrimSpace(input))
+	
+	if input == "list files" || input == "list the files" || input == "ls" {
+		calls = append(calls, ToolCall{Name: "list_dir", Args: map[string]interface{}{}})
+	} else if strings.HasPrefix(input, "read ") {
+		// Extract filename from input
+		filename := strings.TrimPrefix(input, "read ")
+		filename = strings.TrimSpace(filename)
+		if filename != "" {
+			calls = append(calls, ToolCall{Name: "read_file", Args: map[string]interface{}{"path": filename}})
+		}
+	} else if input == "git status" || input == "check git status" || input == "check git" {
+		calls = append(calls, ToolCall{Name: "git_status", Args: map[string]interface{}{}})
+	} else if input == "analyze the code" || input == "analyze code" || input == "analyze this project" {
+		calls = append(calls, ToolCall{Name: "analyze_code", Args: map[string]interface{}{}})
+	} else if strings.HasPrefix(input, "search ") {
+		// Extract search pattern
+		pattern := strings.TrimPrefix(input, "search ")
+		pattern = strings.TrimSpace(pattern)
+		if pattern != "" {
+			calls = append(calls, ToolCall{Name: "search_code", Args: map[string]interface{}{"pattern": pattern}})
+		}
+	} else if strings.HasPrefix(input, "run ") {
+		// Extract command
+		command := strings.TrimPrefix(input, "run ")
+		command = strings.TrimSpace(command)
+		if command != "" {
+			calls = append(calls, ToolCall{Name: "run_command", Args: map[string]interface{}{"command": command}})
+		}
+	}
+	
+	return calls
+}
+
 func (d *Del) processMessage(ctx context.Context, userInput string) {
 	d.emit(StreamMessage{
 		Type:    MessageTypeUser,
@@ -833,16 +956,38 @@ func (d *Del) processMessage(ctx context.Context, userInput string) {
 	
 	d.chatHistory = append(d.chatHistory, api.Message{Role: "user", Content: userInput})
 	
-	// Get response from model
+	// Define tools for Ollama native tool calling
+	tools := d.buildOllamaTools()
+	
+	// Try with native tools first, fall back if not supported
 	var fullResponse string
+	var assistantMessage api.Message
+	var toolsSupported = true
+	
 	err := d.client.Chat(ctx, &api.ChatRequest{
 		Model:    d.model,
 		Messages: d.chatHistory,
+		Tools:    tools,
 	}, func(resp api.ChatResponse) error {
 		fullResponse += resp.Message.Content
+		assistantMessage = resp.Message
 		return nil
 	})
 	
+	// If tools aren't supported, try without tools
+	if err != nil && strings.Contains(err.Error(), "does not support tools") {
+		toolsSupported = false
+		fullResponse = ""
+		err = d.client.Chat(ctx, &api.ChatRequest{
+			Model:    d.model,
+			Messages: d.chatHistory,
+		}, func(resp api.ChatResponse) error {
+			fullResponse += resp.Message.Content
+			assistantMessage = resp.Message
+			return nil
+		})
+	}
+	
 	if err != nil {
 		d.emit(StreamMessage{
 			Type:  MessageTypeSystem,
@@ -851,28 +996,35 @@ func (d *Del) processMessage(ctx context.Context, userInput string) {
 		return
 	}
 	
-	d.chatHistory = append(d.chatHistory, api.Message{Role: "assistant", Content: fullResponse})
-	
-	// Check for tool calls
-	toolCalls := d.parseToolCalls(fullResponse)
-	
-	if len(toolCalls) > 0 {
-		// Execute tools
-		var toolResults []string
-		for _, call := range toolCalls {
+	// Add assistant message to history
+	d.chatHistory = append(d.chatHistory, assistantMessage)
+	
+	// Check for native tool calls from Ollama (only if tools are supported)
+	if toolsSupported && len(assistantMessage.ToolCalls) > 0 {
+		// Execute tools using native Ollama tool calls
+		for _, apiCall := range assistantMessage.ToolCalls {
+			// Convert api.ToolCall to our internal format
+			call := ToolCall{
+				Name: apiCall.Function.Name,
+				Args: apiCall.Function.Arguments,
+			}
+			
+			// Execute the tool
 			result := d.executeTool(ctx, call)
-			toolResults = append(toolResults, fmt.Sprintf("%s: %s", call.Name, result))
+			
+			// Add tool result as a 'tool' role message
+			d.chatHistory = append(d.chatHistory, api.Message{
+				Role:    "tool",
+				Content: result,
+			})
 		}
 		
 		// Get final response with tool results
-		toolResultsMsg := fmt.Sprintf("Tool results:\n%s\n\nProvide a natural response based on these results.", 
-			strings.Join(toolResults, "\n"))
-		d.chatHistory = append(d.chatHistory, api.Message{Role: "user", Content: toolResultsMsg})
-		
 		var finalResponse string
 		err = d.client.Chat(ctx, &api.ChatRequest{
 			Model:    d.model,
 			Messages: d.chatHistory,
+			Tools:    tools,
 		}, func(resp api.ChatResponse) error {
 			finalResponse += resp.Message.Content
 			return nil
@@ -883,8 +1035,38 @@ func (d *Del) processMessage(ctx context.Context, userInput string) {
 			d.streamResponseChunks(ctx, finalResponse)
 		}
 	} else {
-		// No tools, just stream the response
-		d.streamResponseChunks(ctx, fullResponse)
+		// Fallback: Parse user input for tool calls (for models without native support)
+		toolCalls := d.parseTextToolCalls(userInput)
+		if len(toolCalls) > 0 {
+			// Execute any tools found in user input
+			for _, call := range toolCalls {
+				result := d.executeTool(ctx, call)
+				
+				// Add tool result as context for next message
+				d.chatHistory = append(d.chatHistory, api.Message{
+					Role:    "user", 
+					Content: fmt.Sprintf("Tool result for %s: %s", call.Name, result),
+				})
+			}
+			
+			// Get final response with tool results
+			var finalResponse string
+			err = d.client.Chat(ctx, &api.ChatRequest{
+				Model:    d.model,
+				Messages: d.chatHistory,
+			}, func(resp api.ChatResponse) error {
+				finalResponse += resp.Message.Content
+				return nil
+			})
+			
+			if err == nil {
+				d.chatHistory = append(d.chatHistory, api.Message{Role: "assistant", Content: finalResponse})
+				d.streamResponseChunks(ctx, finalResponse)
+			}
+		} else {
+			// No tools, just stream the response
+			d.streamResponseChunks(ctx, fullResponse)
+		}
 	}
 }
 
.gitignore
@@ -1,1 +1,2 @@
 /del
+/bin