diff options
| -rw-r--r-- | bot.go | 31 | ||||
| -rw-r--r-- | tools.go | 115 |
2 files changed, 142 insertions, 4 deletions
@@ -66,6 +66,8 @@ var ( LocalModels = []string{} ) +var thinkBlockRE = regexp.MustCompile(`(?s)<think>.*?</think>`) + // parseKnownToTag extracts known_to list from content using configured tag. // Returns cleaned content and list of character names. func parseKnownToTag(content string) []string { @@ -933,7 +935,9 @@ out: if err := updateStorageChat(activeChatName, chatBody.Messages); err != nil { logger.Warn("failed to update storage", "error", err, "name", activeChatName) } - if findCall(respText.String(), toolResp.String()) { + // Strip think blocks before parsing for tool calls + respTextNoThink := thinkBlockRE.ReplaceAllString(respText.String(), "") + if findCall(respTextNoThink, toolResp.String()) { return nil } // Check if this message was sent privately to specific characters @@ -1077,11 +1081,30 @@ func findCall(msg, toolCall string) bool { if jsStr == "" { // no tool call case return false } - prefix := "__tool_call__\n" - suffix := "\n__tool_call__" - jsStr = strings.TrimSuffix(strings.TrimPrefix(jsStr, prefix), suffix) + // Remove prefix/suffix with flexible whitespace handling + jsStr = strings.TrimSpace(jsStr) + jsStr = strings.TrimPrefix(jsStr, "__tool_call__") + jsStr = strings.TrimSuffix(jsStr, "__tool_call__") + jsStr = strings.TrimSpace(jsStr) // HTML-decode the JSON string to handle encoded characters like < -> <= decodedJsStr := html.UnescapeString(jsStr) + // Try to find valid JSON bounds (first { to last }) + start := strings.Index(decodedJsStr, "{") + end := strings.LastIndex(decodedJsStr, "}") + if start == -1 || end == -1 || end <= start { + logger.Error("failed to find valid JSON in tool call", "json_string", decodedJsStr) + toolResponseMsg := models.RoleMsg{ + Role: cfg.ToolRole, + Content: "Error processing tool call: no valid JSON found. Please check the JSON format.", + } + chatBody.Messages = append(chatBody.Messages, toolResponseMsg) + crr := &models.ChatRoundReq{ + Role: cfg.AssistantRole, + } + chatRoundChan <- crr + return true + } + decodedJsStr = decodedJsStr[start : end+1] var err error fc, err = unmarshalFuncCall(decodedJsStr) if err != nil { @@ -95,6 +95,11 @@ Your current tools: "when_to_use": "when asked to append content to a file; use sed to edit content" }, { +"name":"file_edit", +"args": ["path", "oldString", "newString", "lineNumber"], +"when_to_use": "when you need to make targeted changes to a specific section of a file without rewriting the entire file; lineNumber is optional - if provided, only edits that specific line; if not provided, replaces all occurrences of oldString" +}, +{ "name":"file_delete", "args": ["path"], "when_to_use": "when asked to delete a file" @@ -506,6 +511,85 @@ func fileWriteAppend(args map[string]string) []byte { return []byte(msg) } +func fileEdit(args map[string]string) []byte { + path, ok := args["path"] + if !ok || path == "" { + msg := "path not provided to file_edit tool" + logger.Error(msg) + return []byte(msg) + } + path = resolvePath(path) + + oldString, ok := args["oldString"] + if !ok || oldString == "" { + msg := "oldString not provided to file_edit tool" + logger.Error(msg) + return []byte(msg) + } + + newString, ok := args["newString"] + if !ok { + newString = "" + } + + lineNumberStr, hasLineNumber := args["lineNumber"] + + // Read file content + content, err := os.ReadFile(path) + if err != nil { + msg := "failed to read file: " + err.Error() + logger.Error(msg) + return []byte(msg) + } + + fileContent := string(content) + var replacementCount int + + if hasLineNumber && lineNumberStr != "" { + // Line-number based edit + lineNum, err := strconv.Atoi(lineNumberStr) + if err != nil { + msg := "invalid lineNumber: must be a valid integer" + logger.Error(msg) + return []byte(msg) + } + lines := strings.Split(fileContent, "\n") + if lineNum < 1 || lineNum > len(lines) { + msg := fmt.Sprintf("lineNumber %d out of range (file has %d lines)", lineNum, len(lines)) + logger.Error(msg) + return []byte(msg) + } + // Find oldString in the specific line + targetLine := lines[lineNum-1] + if !strings.Contains(targetLine, oldString) { + msg := fmt.Sprintf("oldString not found on line %d", lineNum) + logger.Error(msg) + return []byte(msg) + } + lines[lineNum-1] = strings.Replace(targetLine, oldString, newString, 1) + replacementCount = 1 + fileContent = strings.Join(lines, "\n") + } else { + // Replace all occurrences + if !strings.Contains(fileContent, oldString) { + msg := "oldString not found in file" + logger.Error(msg) + return []byte(msg) + } + fileContent = strings.ReplaceAll(fileContent, oldString, newString) + replacementCount = strings.Count(fileContent, newString) + } + + if err := os.WriteFile(path, []byte(fileContent), 0644); err != nil { + msg := "failed to write file: " + err.Error() + logger.Error(msg) + return []byte(msg) + } + + msg := fmt.Sprintf("file edited successfully at %s (%d replacement(s))", path, replacementCount) + return []byte(msg) +} + func fileDelete(args map[string]string) []byte { path, ok := args["path"] if !ok || path == "" { @@ -998,6 +1082,7 @@ var fnMap = map[string]fnSig{ "file_read": fileRead, "file_write": fileWrite, "file_write_append": fileWriteAppend, + "file_edit": fileEdit, "file_delete": fileDelete, "file_move": fileMove, "file_copy": fileCopy, @@ -1265,6 +1350,36 @@ var baseTools = []models.Tool{ }, }, }, + // file_edit + models.Tool{ + Type: "function", + Function: models.ToolFunc{ + Name: "file_edit", + Description: "Edit a specific section of a file by replacing oldString with newString. Use for targeted changes without rewriting the entire file.", + Parameters: models.ToolFuncParams{ + Type: "object", + Required: []string{"path", "oldString", "newString"}, + Properties: map[string]models.ToolArgProps{ + "path": models.ToolArgProps{ + Type: "string", + Description: "path of the file to edit", + }, + "oldString": models.ToolArgProps{ + Type: "string", + Description: "the exact string to find and replace", + }, + "newString": models.ToolArgProps{ + Type: "string", + Description: "the string to replace oldString with", + }, + "lineNumber": models.ToolArgProps{ + Type: "string", + Description: "optional line number (1-indexed) to edit - if provided, only that line is edited", + }, + }, + }, + }, + }, // file_delete models.Tool{ Type: "function", |
