Umstellung auf Model Tools

This commit is contained in:
Christoph K.
2026-03-04 07:39:05 +01:00
parent f25bd1b72d
commit c26ccce817
3 changed files with 638 additions and 385 deletions

View File

@@ -1,202 +1,434 @@
// package agent
// import (
// "fmt"
// "os"
// "path/filepath"
// "strings"
// )
// // ─── Tool Registry ────────────────────────────────────────
// type Tool struct {
// Name string
// Description string
// Usage string
// }
// var Registry = []Tool{
// {
// Name: "READ_FILE",
// Description: "Liest den Inhalt einer Datei",
// Usage: "TOOL:READ_FILE:pfad/zur/datei",
// },
// {
// Name: "WRITE_FILE",
// Description: "Schreibt Inhalt in eine Datei (mehrzeilig möglich)",
// Usage: `TOOL:WRITE_FILE:pfad/zur/datei
// <<<
// dateiinhalt hier
// >>>`,
// },
// {
// Name: "LIST_FILES",
// Description: "Listet alle Dateien in einem Verzeichnis",
// Usage: "TOOL:LIST_FILES:pfad",
// },
// }
// // BuildToolPrompt generiert den Tool-Abschnitt für den System-Prompt
// func BuildToolPrompt() string {
// var sb strings.Builder
// sb.WriteString("Du hast folgende Tools zur Verfügung:\n\n")
// for _, t := range Registry {
// sb.WriteString(fmt.Sprintf("### %s\n", t.Name))
// sb.WriteString(fmt.Sprintf("Beschreibung: %s\n", t.Description))
// sb.WriteString(fmt.Sprintf("Verwendung:\n%s\n\n", t.Usage))
// }
// return sb.String()
// }
// // ─── Tool Parsing ─────────────────────────────────────────
// type toolCall struct {
// name string
// path string
// content string // nur für WRITE_FILE
// }
// // parseToolCalls extrahiert alle Tool-Calls aus einer LLM-Antwort.
// // Unterstützt mehrzeilige WRITE_FILE Blöcke:
// //
// // TOOL:WRITE_FILE:pfad
// // <<<
// // inhalt
// // >>>
// func parseToolCalls(response string) []toolCall {
// var calls []toolCall
// lines := strings.Split(response, "\n")
// i := 0
// for i < len(lines) {
// line := strings.TrimSpace(lines[i])
// if !strings.HasPrefix(line, "TOOL:") {
// i++
// continue
// }
// parts := strings.SplitN(line, ":", 3)
// if len(parts) < 3 {
// i++
// continue
// }
// toolName := parts[1]
// toolPath := parts[2]
// // WRITE_FILE: Block-Inhalt lesen (<<<...>>>)
// if toolName == "WRITE_FILE" {
// content, newIndex := readContentBlock(lines, i+1)
// calls = append(calls, toolCall{
// name: toolName,
// path: toolPath,
// content: content,
// })
// i = newIndex
// continue
// }
// calls = append(calls, toolCall{
// name: toolName,
// path: toolPath,
// })
// i++
// }
// return calls
// }
// // readContentBlock liest Zeilen zwischen <<< und >>> und gibt den
// // bereinigten Inhalt sowie den neuen Zeilenindex zurück.
// func readContentBlock(lines []string, startIndex int) (string, int) {
// i := startIndex
// // Öffnendes <<< überspringen (optional, falls LLM es ausgibt)
// if i < len(lines) && strings.TrimSpace(lines[i]) == "<<<" {
// i++
// }
// var contentLines []string
// for i < len(lines) {
// trimmed := strings.TrimSpace(lines[i])
// if trimmed == ">>>" {
// i++ // >>> konsumieren
// break
// }
// contentLines = append(contentLines, lines[i])
// i++
// }
// return strings.Join(contentLines, "\n"), i
// }
// // ─── Tool Execution ───────────────────────────────────────
// // ExecuteTools parst alle Tool-Calls aus der LLM-Antwort und führt sie aus.
// // Gibt den kombinierten Output und true zurück wenn mindestens ein Tool aufgerufen wurde.
// func ExecuteTools(response string, workDir string) (string, bool) {
// calls := parseToolCalls(response)
// if len(calls) == 0 {
// return "", false
// }
// var outputs []string
// for _, call := range calls {
// result := executeToolCall(call, workDir)
// outputs = append(outputs, result)
// }
// return strings.Join(outputs, "\n"), true
// }
// func executeToolCall(call toolCall, workDir string) string {
// // Sicherheits-Check: Path Traversal verhindern
// safePath, err := sanitizePath(workDir, call.path)
// if err != nil {
// return fmt.Sprintf("ERROR: Ungültiger Pfad %q: %v", call.path, err)
// }
// switch call.name {
// case "READ_FILE":
// return readFile(safePath, call.path)
// case "WRITE_FILE":
// return writeFile(safePath, call.path, call.content)
// case "LIST_FILES":
// return listFiles(safePath, call.path)
// default:
// return fmt.Sprintf("ERROR: Unbekanntes Tool %q", call.name)
// }
// }
// // ─── Einzelne Tool-Implementierungen ─────────────────────
// func readFile(absPath, displayPath string) string {
// content, err := os.ReadFile(absPath)
// if err != nil {
// return fmt.Sprintf("READ_FILE ERROR: %v", err)
// }
// return fmt.Sprintf("READ_FILE %s:\n%s", displayPath, string(content))
// }
// func writeFile(absPath, displayPath, content string) string {
// content = cleanContent(content)
// if err := os.MkdirAll(filepath.Dir(absPath), 0755); err != nil {
// return fmt.Sprintf("WRITE_FILE ERROR: Verzeichnis anlegen fehlgeschlagen: %v", err)
// }
// if err := os.WriteFile(absPath, []byte(content), 0644); err != nil {
// return fmt.Sprintf("WRITE_FILE ERROR: %v", err)
// }
// return fmt.Sprintf("WRITE_FILE OK: %s geschrieben (%d Bytes)", displayPath, len(content))
// }
// func listFiles(absPath, displayPath string) string {
// entries, err := os.ReadDir(absPath)
// if err != nil {
// return fmt.Sprintf("LIST_FILES ERROR: %v", err)
// }
// if len(entries) == 0 {
// return fmt.Sprintf("LIST_FILES %s: (leer)", displayPath)
// }
// var files []string
// for _, e := range entries {
// if e.IsDir() {
// files = append(files, e.Name()+"/")
// } else {
// files = append(files, e.Name())
// }
// }
// return fmt.Sprintf("LIST_FILES %s:\n%s", displayPath, strings.Join(files, "\n"))
// }
// // ─── Sicherheit ───────────────────────────────────────────
// // sanitizePath stellt sicher dass der Pfad innerhalb des workDir bleibt.
// // Verhindert Directory Traversal wie ../../etc/passwd
// func sanitizePath(workDir, relPath string) (string, error) {
// // Wenn LLM einen absoluten Pfad schickt → relativen Teil extrahieren
// if filepath.IsAbs(relPath) {
// workDirClean := filepath.Clean(workDir)
// // Prüfen ob der absolute Pfad innerhalb des workDir liegt
// if strings.HasPrefix(relPath, workDirClean) {
// // Absoluten Pfad direkt nutzen, kein Join nötig
// return filepath.Clean(relPath), nil
// }
// // Absoluter Pfad außerhalb workDir → nur Dateiname nehmen
// relPath = filepath.Base(relPath)
// }
// // Normaler Fall: relativer Pfad
// abs := filepath.Clean(filepath.Join(workDir, relPath))
// workDirClean := filepath.Clean(workDir)
// if !strings.HasPrefix(abs, workDirClean+string(filepath.Separator)) &&
// abs != workDirClean {
// return "", fmt.Errorf("Pfad außerhalb des Arbeitsverzeichnisses")
// }
// return abs, nil
// }
// func cleanContent(content string) string {
// // Escaped Quotes normalisieren
// content = strings.ReplaceAll(content, `\"`, `"`)
// content = strings.ReplaceAll(content, `\\n`, "\n")
// content = strings.ReplaceAll(content, `\\t`, "\t")
// // Markdown Codeblöcke entfernen
// lines := strings.Split(content, "\n")
// var cleaned []string
// for _, line := range lines {
// if strings.HasPrefix(strings.TrimSpace(line), "```") {
// continue
// }
// cleaned = append(cleaned, line)
// }
// return strings.TrimSpace(strings.Join(cleaned, "\n"))
// }
package agent
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/openai/openai-go"
)
// ─── Tool Registry ────────────────────────────────────────
// ─── Tool Definitionen ────────────────────────────────────
type Tool struct {
Name string
Description string
Usage string
}
var Registry = []Tool{
var Tools = []openai.ChatCompletionToolParam{
{
Name: "READ_FILE",
Description: "Liest den Inhalt einer Datei",
Usage: "TOOL:READ_FILE:pfad/zur/datei",
Type: "function",
Function: openai.FunctionDefinitionParam{
Name: "write_file",
Description: openai.String("Schreibt Inhalt in eine Datei. Erstellt Verzeichnisse automatisch."),
Parameters: openai.FunctionParameters{
"type": "object",
"properties": map[string]any{
"path": map[string]any{
"type": "string",
"description": "Relativer Pfad der Datei z.B. main.go oder subdir/file.go",
},
"content": map[string]any{
"type": "string",
"description": "Vollständiger Inhalt der Datei",
},
},
"required": []string{"path", "content"},
},
},
},
{
Name: "WRITE_FILE",
Description: "Schreibt Inhalt in eine Datei (mehrzeilig möglich)",
Usage: `TOOL:WRITE_FILE:pfad/zur/datei
<<<
dateiinhalt hier
>>>`,
Type: "function",
Function: openai.FunctionDefinitionParam{
Name: "read_file",
Description: openai.String("Liest den Inhalt einer existierenden Datei"),
Parameters: openai.FunctionParameters{
"type": "object",
"properties": map[string]any{
"path": map[string]any{
"type": "string",
"description": "Relativer Pfad der Datei",
},
},
"required": []string{"path"},
},
},
},
{
Name: "LIST_FILES",
Description: "Listet alle Dateien in einem Verzeichnis",
Usage: "TOOL:LIST_FILES:pfad",
Type: "function",
Function: openai.FunctionDefinitionParam{
Name: "list_files",
Description: openai.String("Listet alle Dateien und Verzeichnisse in einem Pfad auf"),
Parameters: openai.FunctionParameters{
"type": "object",
"properties": map[string]any{
"path": map[string]any{
"type": "string",
"description": "Relativer Verzeichnispfad, '.' für aktuelles Verzeichnis",
},
},
"required": []string{"path"},
},
},
},
{
Type: "function",
Function: openai.FunctionDefinitionParam{
Name: "task_complete",
Description: openai.String("Rufe dies auf wenn der Task vollständig und korrekt erledigt ist"),
Parameters: openai.FunctionParameters{
"type": "object",
"properties": map[string]any{},
},
},
},
}
// BuildToolPrompt generiert den Tool-Abschnitt für den System-Prompt
func BuildToolPrompt() string {
var sb strings.Builder
sb.WriteString("Du hast folgende Tools zur Verfügung:\n\n")
for _, t := range Registry {
sb.WriteString(fmt.Sprintf("### %s\n", t.Name))
sb.WriteString(fmt.Sprintf("Beschreibung: %s\n", t.Description))
sb.WriteString(fmt.Sprintf("Verwendung:\n%s\n\n", t.Usage))
}
return sb.String()
}
// ─── Tool Parsing ─────────────────────────────────────────
type toolCall struct {
name string
path string
content string // nur für WRITE_FILE
}
// parseToolCalls extrahiert alle Tool-Calls aus einer LLM-Antwort.
// Unterstützt mehrzeilige WRITE_FILE Blöcke:
//
// TOOL:WRITE_FILE:pfad
// <<<
// inhalt
// >>>
func parseToolCalls(response string) []toolCall {
var calls []toolCall
lines := strings.Split(response, "\n")
i := 0
for i < len(lines) {
line := strings.TrimSpace(lines[i])
if !strings.HasPrefix(line, "TOOL:") {
i++
continue
}
parts := strings.SplitN(line, ":", 3)
if len(parts) < 3 {
i++
continue
}
toolName := parts[1]
toolPath := parts[2]
// WRITE_FILE: Block-Inhalt lesen (<<<...>>>)
if toolName == "WRITE_FILE" {
content, newIndex := readContentBlock(lines, i+1)
calls = append(calls, toolCall{
name: toolName,
path: toolPath,
content: content,
})
i = newIndex
continue
}
calls = append(calls, toolCall{
name: toolName,
path: toolPath,
})
i++
}
return calls
}
// readContentBlock liest Zeilen zwischen <<< und >>> und gibt den
// bereinigten Inhalt sowie den neuen Zeilenindex zurück.
func readContentBlock(lines []string, startIndex int) (string, int) {
i := startIndex
// Öffnendes <<< überspringen (optional, falls LLM es ausgibt)
if i < len(lines) && strings.TrimSpace(lines[i]) == "<<<" {
i++
}
var contentLines []string
for i < len(lines) {
trimmed := strings.TrimSpace(lines[i])
if trimmed == ">>>" {
i++ // >>> konsumieren
break
}
contentLines = append(contentLines, lines[i])
i++
}
return strings.Join(contentLines, "\n"), i
}
// ─── Tool Execution ───────────────────────────────────────
// ExecuteTools parst alle Tool-Calls aus der LLM-Antwort und führt sie aus.
// Gibt den kombinierten Output und true zurück wenn mindestens ein Tool aufgerufen wurde.
func ExecuteTools(response string, workDir string) (string, bool) {
calls := parseToolCalls(response)
if len(calls) == 0 {
return "", false
}
var outputs []string
for _, call := range calls {
result := executeToolCall(call, workDir)
outputs = append(outputs, result)
}
return strings.Join(outputs, "\n"), true
type ToolExecutor struct {
workDir string
}
func executeToolCall(call toolCall, workDir string) string {
// Sicherheits-Check: Path Traversal verhindern
safePath, err := sanitizePath(workDir, call.path)
func NewToolExecutor(workDir string) *ToolExecutor {
return &ToolExecutor{workDir: workDir}
}
// Execute führt einen Tool-Call aus.
// Gibt (result, done) zurück done=true bedeutet task_complete wurde aufgerufen.
func (e *ToolExecutor) Execute(toolCall openai.ChatCompletionMessageToolCall) (string, bool) {
name := toolCall.Function.Name
args := toolCall.Function.Arguments
switch name {
case "task_complete":
return "Task erfolgreich abgeschlossen.", true
case "write_file":
var p struct {
Path string `json:"path"`
Content string `json:"content"`
}
if err := json.Unmarshal([]byte(args), &p); err != nil {
return fmt.Sprintf("ERROR: Ungültige Parameter: %v", err), false
}
return e.writeFile(p.Path, p.Content), false
case "read_file":
var p struct {
Path string `json:"path"`
}
if err := json.Unmarshal([]byte(args), &p); err != nil {
return fmt.Sprintf("ERROR: Ungültige Parameter: %v", err), false
}
return e.readFile(p.Path), false
case "list_files":
var p struct {
Path string `json:"path"`
}
if err := json.Unmarshal([]byte(args), &p); err != nil {
return fmt.Sprintf("ERROR: Ungültige Parameter: %v", err), false
}
return e.listFiles(p.Path), false
}
return fmt.Sprintf("ERROR: Unbekanntes Tool %q", name), false
}
// ─── Implementierungen ────────────────────────────────────
func (e *ToolExecutor) writeFile(relPath, content string) string {
absPath, err := e.sanitizePath(relPath)
if err != nil {
return fmt.Sprintf("ERROR: Ungültiger Pfad %q: %v", call.path, err)
return fmt.Sprintf("ERROR: %v", err)
}
switch call.name {
case "READ_FILE":
return readFile(safePath, call.path)
case "WRITE_FILE":
return writeFile(safePath, call.path, call.content)
case "LIST_FILES":
return listFiles(safePath, call.path)
default:
return fmt.Sprintf("ERROR: Unbekanntes Tool %q", call.name)
}
}
// ─── Einzelne Tool-Implementierungen ─────────────────────
func readFile(absPath, displayPath string) string {
content, err := os.ReadFile(absPath)
if err != nil {
return fmt.Sprintf("READ_FILE ERROR: %v", err)
}
return fmt.Sprintf("READ_FILE %s:\n%s", displayPath, string(content))
}
func writeFile(absPath, displayPath, content string) string {
content = cleanContent(content)
if err := os.MkdirAll(filepath.Dir(absPath), 0755); err != nil {
return fmt.Sprintf("WRITE_FILE ERROR: Verzeichnis anlegen fehlgeschlagen: %v", err)
return fmt.Sprintf("ERROR: Verzeichnis anlegen: %v", err)
}
if err := os.WriteFile(absPath, []byte(content), 0644); err != nil {
return fmt.Sprintf("WRITE_FILE ERROR: %v", err)
return fmt.Sprintf("ERROR: Schreiben fehlgeschlagen: %v", err)
}
return fmt.Sprintf("WRITE_FILE OK: %s geschrieben (%d Bytes)", displayPath, len(content))
return fmt.Sprintf("OK: %s geschrieben (%d Bytes)", relPath, len(content))
}
func listFiles(absPath, displayPath string) string {
func (e *ToolExecutor) readFile(relPath string) string {
absPath, err := e.sanitizePath(relPath)
if err != nil {
return fmt.Sprintf("ERROR: %v", err)
}
content, err := os.ReadFile(absPath)
if err != nil {
return fmt.Sprintf("ERROR: Datei nicht gefunden: %v", err)
}
return fmt.Sprintf("OK: Inhalt von %s:\n%s", relPath, string(content))
}
func (e *ToolExecutor) listFiles(relPath string) string {
absPath, err := e.sanitizePath(relPath)
if err != nil {
return fmt.Sprintf("ERROR: %v", err)
}
entries, err := os.ReadDir(absPath)
if err != nil {
return fmt.Sprintf("LIST_FILES ERROR: %v", err)
return fmt.Sprintf("ERROR: Verzeichnis nicht lesbar: %v", err)
}
if len(entries) == 0 {
return fmt.Sprintf("LIST_FILES %s: (leer)", displayPath)
return fmt.Sprintf("OK: %s ist leer", relPath)
}
var files []string
for _, e := range entries {
@@ -206,52 +438,27 @@ func listFiles(absPath, displayPath string) string {
files = append(files, e.Name())
}
}
return fmt.Sprintf("LIST_FILES %s:\n%s", displayPath, strings.Join(files, "\n"))
return fmt.Sprintf("OK: Dateien in %s:\n%s", relPath, strings.Join(files, "\n"))
}
// ─── Sicherheit ───────────────────────────────────────────
// sanitizePath stellt sicher dass der Pfad innerhalb des workDir bleibt.
// Verhindert Directory Traversal wie ../../etc/passwd
func sanitizePath(workDir, relPath string) (string, error) {
// Wenn LLM einen absoluten Pfad schickt → relativen Teil extrahieren
func (e *ToolExecutor) sanitizePath(relPath string) (string, error) {
// Absolute Pfade vom LLM: relativen Teil extrahieren
if filepath.IsAbs(relPath) {
workDirClean := filepath.Clean(workDir)
// Prüfen ob der absolute Pfad innerhalb des workDir liegt
workDirClean := filepath.Clean(e.workDir)
if strings.HasPrefix(relPath, workDirClean) {
// Absoluten Pfad direkt nutzen, kein Join nötig
return filepath.Clean(relPath), nil
}
// Absoluter Pfad außerhalb workDir → nur Dateiname nehmen
relPath = filepath.Base(relPath)
}
// Normaler Fall: relativer Pfad
abs := filepath.Clean(filepath.Join(workDir, relPath))
workDirClean := filepath.Clean(workDir)
abs := filepath.Clean(filepath.Join(e.workDir, relPath))
workDirClean := filepath.Clean(e.workDir)
if !strings.HasPrefix(abs, workDirClean+string(filepath.Separator)) &&
abs != workDirClean {
return "", fmt.Errorf("Pfad außerhalb des Arbeitsverzeichnisses")
return "", fmt.Errorf("Pfad außerhalb des Arbeitsverzeichnisses: %s", relPath)
}
return abs, nil
}
func cleanContent(content string) string {
// Escaped Quotes normalisieren
content = strings.ReplaceAll(content, `\"`, `"`)
content = strings.ReplaceAll(content, `\\n`, "\n")
content = strings.ReplaceAll(content, `\\t`, "\t")
// Markdown Codeblöcke entfernen
lines := strings.Split(content, "\n")
var cleaned []string
for _, line := range lines {
if strings.HasPrefix(strings.TrimSpace(line), "```") {
continue
}
cleaned = append(cleaned, line)
}
return strings.TrimSpace(strings.Join(cleaned, "\n"))
}