diff --git a/agent/loop.go b/agent/loop.go index be4f9e9..b96a655 100644 --- a/agent/loop.go +++ b/agent/loop.go @@ -18,16 +18,10 @@ const ( maxTurns = 10 ) -var systemPrompt = `Du bist ein Coding-Agent und programmierst Go. -Erledige deine Aufgabe mit folgenden Tools: -TOOL:READ_FILE:pfad -TOOL:WRITE_FILE:pfad:<<>> -TOOL:LIST_FILES:pfad - -REGELN: -- Nutze relative Pfade -- Kein Markdown in Dateiinhalten -- Wenn Task erledigt: schreibe nur TASK_COMPLETE` +var systemPrompt = `Du bist ein autonomer Coding-Agent. +Erledige den gegebenen Task vollstΓ€ndig mit den bereitgestellten Tools. +Rufe task_complete auf sobald der Task erledigt ist. +Nutze ausschließlich relative Pfade.` type AgentLoop struct { client *openai.Client @@ -74,6 +68,7 @@ func (a *AgentLoop) Run() error { a.log.TaskStart(task.Title) success := false + var lastErr error for attempt := 1; attempt <= maxRetries; attempt++ { if attempt > 1 { a.log.Info("πŸ” Retry %d/%d...", attempt, maxRetries) @@ -84,6 +79,7 @@ func (a *AgentLoop) Run() error { success = true break } else { + lastErr = err a.log.Info("⚠️ Fehler: %v", err) } } @@ -93,6 +89,7 @@ func (a *AgentLoop) Run() error { a.log.TaskDone(task.Title) } else { a.log.TaskFailed(task.Title, maxRetries) + _ = lastErr } } @@ -101,137 +98,116 @@ func (a *AgentLoop) Run() error { } func (a *AgentLoop) runTask(task prd.Task) error { + executor := NewToolExecutor(a.workDir) + // Frischer Kontext pro Task messages := []openai.ChatCompletionMessageParamUnion{ openai.SystemMessage(systemPrompt), openai.UserMessage(fmt.Sprintf( "Task: %s\nArbeitsverzeichnis: %s", - task.Title, - a.workDir, + task.Title, a.workDir, )), } a.log.ChatMessage("system", systemPrompt) a.log.ChatMessage("user", fmt.Sprintf( "Task: %s\nArbeitsverzeichnis: %s", - task.Title, - a.workDir, + task.Title, a.workDir, )) for turn := 0; turn < maxTurns; turn++ { a.log.Turn(turn + 1) - // Token-SchΓ€tzung fΓΌr Debugging totalChars := 0 for _, m := range messages { totalChars += len(fmt.Sprintf("%v", m)) } + start := time.Now() - a.log.Debug("MODEL REQUEST: model=%s ~%d Zeichen\n%s", a.model, totalChars, formatMessages(messages)) + a.log.Debug("MODEL REQUEST: model=%s ~%d Zeichen\n%s", + a.model, totalChars, formatMessages(messages)) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) resp, err := a.client.Chat.Completions.New( - context.Background(), + ctx, openai.ChatCompletionNewParams{ Model: a.model, Messages: messages, + Tools: Tools, // ← Tool Calling }, ) + cancel() + elapsed := time.Since(start) if resp != nil && len(resp.Choices) > 0 { a.log.Debug("MODEL RESPONSE\n%s", formatResponse(resp, elapsed)) } if err != nil { - return fmt.Errorf("API-Fehler (~%d Zeichen im Kontext): %w", totalChars, err) + return fmt.Errorf("API-Fehler (~%d Zeichen): %w", totalChars, err) } - response := resp.Choices[0].Message.Content - a.log.ChatMessage("assistant", response) - messages = append(messages, openai.AssistantMessage(response)) + choice := resp.Choices[0] - // Completion Detection - if isTaskComplete(response) { - if turn == 0 { - // LLM hat sofort TASK_COMPLETE ohne Tool-Call β†’ nichts wurde getan - nudge := "Du hast die Datei noch nicht erstellt! Nutze zuerst WRITE_FILE, dann schreibe TASK_COMPLETE." - a.log.ChatMessage("user", nudge) - messages = append(messages, openai.UserMessage(nudge)) - continue // nΓ€chster Turn - } - return nil - } + // Antwort zur History hinzufΓΌgen + messages = append(messages, choice.Message.ToParam()) - // Tool Execution - toolOutput, hadTools := ExecuteTools(response, a.workDir) - if hadTools { - a.log.ChatMessage("tool", toolOutput) - messages = append(messages, openai.UserMessage(toolOutput)) + // Kein Tool-Call β†’ LLM hat nur Text geantwortet + if len(choice.Message.ToolCalls) == 0 { + a.log.ChatMessage("assistant", choice.Message.Content) + nudge := "Nutze die bereitgestellten Tools. Rufe task_complete auf wenn du fertig bist." + a.log.ChatMessage("user", nudge) + messages = append(messages, openai.UserMessage(nudge)) continue } - // Kein Tool, kein TASK_COMPLETE β†’ anstupsen - nudge := "Fahre fort. Wenn der Task erledigt ist, schreibe TASK_COMPLETE." - a.log.ChatMessage("user", nudge) - messages = append(messages, openai.UserMessage(nudge)) - } + // Tool-Calls ausfΓΌhren + for _, toolCall := range choice.Message.ToolCalls { + a.log.Info(" πŸ”§ %s(%s)", toolCall.Function.Name, + truncate(toolCall.Function.Arguments, 80)) - return fmt.Errorf("maximale Turns (%d) erreicht ohne TASK_COMPLETE", maxTurns) -} + result, done := executor.Execute(toolCall) + a.log.ChatMessage("tool", + fmt.Sprintf("%s β†’ %s", toolCall.Function.Name, result)) -// isTaskComplete erkennt TASK_COMPLETE auch bei hΓ€ufigen LLM-Tippfehlern -func isTaskComplete(response string) bool { - if strings.Contains(response, "TASK_COMPLETE") { - return true - } - typos := []string{ - "TUTK_COMPLETE", - "TASK_COMPLET", - "TASK_COMPLETED", - "TASK_COMPETE", - "TAKS_COMPLETE", - } - upper := strings.ToUpper(response) - for _, t := range typos { - if strings.Contains(upper, t) { - return true + // Tool-Ergebnis zurΓΌck ans LLM + messages = append(messages, openai.ToolMessage(result, toolCall.ID)) + + if done { + return nil // task_complete aufgerufen β†’ Erfolg + } } } - return false + + return fmt.Errorf("maximale Turns (%d) erreicht", maxTurns) +} + +// ─── Hilfsfunktionen ───────────────────────────────────── + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max] + "..." } -// formatMessages gibt die Chat-History lesbar aus func formatMessages(messages []openai.ChatCompletionMessageParamUnion) string { var sb strings.Builder for i, m := range messages { var role, content string - switch { case m.OfSystem != nil: role = "system" - if len(m.OfSystem.Content.OfString.Value) > 0 { - content = m.OfSystem.Content.OfString.Value - } + content = m.OfSystem.Content.OfString.Value case m.OfUser != nil: role = "user" - if len(m.OfUser.Content.OfString.Value) > 0 { - content = m.OfUser.Content.OfString.Value - } + content = m.OfUser.Content.OfString.Value case m.OfAssistant != nil: role = "assistant" - if len(m.OfAssistant.Content.OfString.Value) > 0 { - content = m.OfAssistant.Content.OfString.Value - } + content = m.OfAssistant.Content.OfString.Value default: - role = "unknown" - content = fmt.Sprintf("%+v", m) + role = "other" } - - // Inhalt auf 120 Zeichen kΓΌrzen fΓΌr Übersicht - preview := content - if len(preview) > 120 { - preview = preview[:120] + "..." - } - // ZeilenumbrΓΌche fΓΌr einzeilige Darstellung ersetzen - preview = strings.ReplaceAll(preview, "\n", "↡") - + preview := strings.ReplaceAll(truncate(content, 120), "\n", "↡") sb.WriteString(fmt.Sprintf(" [%d] %-10s : %s\n", i, role, preview)) } return sb.String() @@ -239,7 +215,6 @@ func formatMessages(messages []openai.ChatCompletionMessageParamUnion) string { func formatResponse(resp *openai.ChatCompletion, elapsed time.Duration) string { var sb strings.Builder - sb.WriteString(fmt.Sprintf(" ID : %s\n", resp.ID)) sb.WriteString(fmt.Sprintf(" Modell : %s\n", resp.Model)) sb.WriteString(fmt.Sprintf(" Elapsed : %s\n", elapsed.Round(time.Millisecond))) @@ -250,17 +225,21 @@ func formatResponse(resp *openai.ChatCompletion, elapsed time.Duration) string { resp.Usage.TotalTokens, )) - // Tokens/Sekunde aus den Timing-Daten (Ollama-spezifisch) - if timings, ok := resp.JSON.ExtraFields["timings"]; ok { - sb.WriteString(fmt.Sprintf(" Timings : %s\n", timings.Raw())) + // Tool-Calls anzeigen + if len(resp.Choices[0].Message.ToolCalls) > 0 { + sb.WriteString(" Tool-Calls :\n") + for _, tc := range resp.Choices[0].Message.ToolCalls { + sb.WriteString(fmt.Sprintf(" β†’ %s(%s)\n", + tc.Function.Name, + truncate(tc.Function.Arguments, 100), + )) + } + } else { + content := resp.Choices[0].Message.Content + sb.WriteString(" Content :\n") + for _, line := range strings.Split(content, "\n") { + sb.WriteString(fmt.Sprintf(" %s\n", line)) + } } - - sb.WriteString(fmt.Sprintf(" Content :\n")) - // Inhalt eingerΓΌckt und vollstΓ€ndig ausgeben - content := resp.Choices[0].Message.Content - for _, line := range strings.Split(content, "\n") { - sb.WriteString(fmt.Sprintf(" %s\n", line)) - } - return sb.String() } diff --git a/agent/tools.go b/agent/tools.go index dfb58f2..f22bae7 100644 --- a/agent/tools.go +++ b/agent/tools.go @@ -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")) -} diff --git a/agent/tools_test.go b/agent/tools_test.go index f571644..736969e 100644 --- a/agent/tools_test.go +++ b/agent/tools_test.go @@ -3,48 +3,64 @@ package agent import ( "os" "path/filepath" + "strings" "testing" + + "github.com/openai/openai-go" ) -// Hilfsfunktion: temporΓ€res Arbeitsverzeichnis anlegen -func setupWorkDir(t *testing.T) string { +// ─── Hilfsfunktionen ───────────────────────────────────── + +func setupWorkDir(t *testing.T) (string, *ToolExecutor) { t.Helper() dir, err := os.MkdirTemp("", "agent-tools-test-*") if err != nil { t.Fatalf("Konnte temp dir nicht anlegen: %v", err) } - t.Cleanup(func() { os.RemoveAll(dir) }) // wird nach jedem Test aufgerΓ€umt - return dir + t.Cleanup(func() { os.RemoveAll(dir) }) + return dir, NewToolExecutor(dir) } -// ─── WRITE_FILE ────────────────────────────────────────── +func makeToolCall(name, args string) openai.ChatCompletionMessageToolCall { + return openai.ChatCompletionMessageToolCall{ + ID: "test-call-id", + Type: "function", + Function: openai.ChatCompletionMessageToolCallFunction{ + Name: name, + Arguments: args, + }, + } +} + +// ─── WRITE_FILE ─────────────────────────────────────────── func TestWriteFile_CreatesFile(t *testing.T) { - dir := setupWorkDir(t) + dir, ex := setupWorkDir(t) - toolCall := "TOOL:WRITE_FILE:hello.go:package main\n\nfunc main() {}" - output := executeTool(toolCall, dir) + result, done := ex.Execute(makeToolCall("write_file", + `{"path":"hello.go","content":"package main"}`)) - if output != "WRITE_FILE OK: hello.go geschrieben" { - t.Errorf("Unerwarteter Output: %q", output) + if done { + t.Error("write_file sollte done=false zurΓΌckgeben") } - - // Datei muss wirklich existieren - path := filepath.Join(dir, "hello.go") - if _, err := os.Stat(path); os.IsNotExist(err) { - t.Error("Datei wurde nicht angelegt") + if !strings.Contains(result, "OK") { + t.Errorf("Erwartete OK im Ergebnis, bekam: %q", result) + } + if _, err := os.Stat(filepath.Join(dir, "hello.go")); os.IsNotExist(err) { + t.Error("Datei hello.go wurde nicht erstellt") } } func TestWriteFile_CorrectContent(t *testing.T) { - dir := setupWorkDir(t) + dir, ex := setupWorkDir(t) expected := "package main\n\nfunc main() {}" - executeTool("TOOL:WRITE_FILE:hello.go:"+expected, dir) + ex.Execute(makeToolCall("write_file", + `{"path":"main.go","content":"package main\n\nfunc main() {}"}`)) - content, err := os.ReadFile(filepath.Join(dir, "hello.go")) + content, err := os.ReadFile(filepath.Join(dir, "main.go")) if err != nil { - t.Fatalf("Datei konnte nicht gelesen werden: %v", err) + t.Fatalf("Datei nicht lesbar: %v", err) } if string(content) != expected { t.Errorf("Inhalt falsch\n erwartet: %q\n bekommen: %q", expected, string(content)) @@ -52,9 +68,10 @@ func TestWriteFile_CorrectContent(t *testing.T) { } func TestWriteFile_CreatesSubdirectory(t *testing.T) { - dir := setupWorkDir(t) + dir, ex := setupWorkDir(t) - executeTool("TOOL:WRITE_FILE:subdir/nested/file.go:package sub", dir) + ex.Execute(makeToolCall("write_file", + `{"path":"subdir/nested/file.go","content":"package sub"}`)) path := filepath.Join(dir, "subdir", "nested", "file.go") if _, err := os.Stat(path); os.IsNotExist(err) { @@ -62,135 +79,185 @@ func TestWriteFile_CreatesSubdirectory(t *testing.T) { } } -func TestWriteFile_MissingContent_ReturnsError(t *testing.T) { - dir := setupWorkDir(t) +func TestWriteFile_MultilineContent(t *testing.T) { + dir, ex := setupWorkDir(t) - output := executeTool("TOOL:WRITE_FILE:hello.go", dir) + ex.Execute(makeToolCall("write_file", + `{"path":"main.go","content":"package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Println(\"http://localhost:8080\")\n}"}`)) - if output != "ERROR: WRITE_FILE braucht Inhalt" { - t.Errorf("Erwartete Fehlermeldung, bekam: %q", output) + content, err := os.ReadFile(filepath.Join(dir, "main.go")) + if err != nil { + t.Fatal("Datei nicht gefunden") + } + if !strings.Contains(string(content), "http://localhost:8080") { + t.Error("Inhalt mit Doppelpunkt/URL wurde falsch geschrieben") } } -// ─── READ_FILE ─────────────────────────────────────────── +func TestWriteFile_InvalidJSON_ReturnsError(t *testing.T) { + _, ex := setupWorkDir(t) + + result, done := ex.Execute(makeToolCall("write_file", `{invalid json}`)) + + if done { + t.Error("Sollte done=false zurΓΌckgeben") + } + if !strings.Contains(result, "ERROR") { + t.Errorf("Erwartete ERROR, bekam: %q", result) + } +} + +// ─── READ_FILE ──────────────────────────────────────────── func TestReadFile_ReadsExistingFile(t *testing.T) { - dir := setupWorkDir(t) - expected := "Hello, World!" + dir, ex := setupWorkDir(t) + os.WriteFile(filepath.Join(dir, "test.txt"), []byte("Hello World"), 0644) - // Datei vorbereiten - os.WriteFile(filepath.Join(dir, "test.txt"), []byte(expected), 0644) + result, done := ex.Execute(makeToolCall("read_file", + `{"path":"test.txt"}`)) - output := executeTool("TOOL:READ_FILE:test.txt", dir) - - if output != "READ_FILE test.txt:\n"+expected { - t.Errorf("Unerwarteter Output: %q", output) + if done { + t.Error("read_file sollte done=false zurΓΌckgeben") + } + if !strings.Contains(result, "Hello World") { + t.Errorf("Dateiinhalt fehlt im Ergebnis: %q", result) } } func TestReadFile_NonExistentFile_ReturnsError(t *testing.T) { - dir := setupWorkDir(t) + _, ex := setupWorkDir(t) - output := executeTool("TOOL:READ_FILE:gibts-nicht.txt", dir) + result, _ := ex.Execute(makeToolCall("read_file", + `{"path":"gibts-nicht.txt"}`)) - if output == "" || output[:15] != "READ_FILE ERROR" { - t.Errorf("Erwartete Fehlermeldung, bekam: %q", output) + if !strings.Contains(result, "ERROR") { + t.Errorf("Erwartete ERROR, bekam: %q", result) } } -// ─── LIST_FILES ────────────────────────────────────────── +func TestReadFile_InvalidJSON_ReturnsError(t *testing.T) { + _, ex := setupWorkDir(t) -func TestListFiles_ReturnsFileNames(t *testing.T) { - dir := setupWorkDir(t) + result, _ := ex.Execute(makeToolCall("read_file", `{bad}`)) - // Testdateien anlegen + if !strings.Contains(result, "ERROR") { + t.Errorf("Erwartete ERROR, bekam: %q", result) + } +} + +// ─── LIST_FILES ─────────────────────────────────────────── + +func TestListFiles_ShowsFiles(t *testing.T) { + dir, ex := setupWorkDir(t) os.WriteFile(filepath.Join(dir, "a.go"), []byte(""), 0644) os.WriteFile(filepath.Join(dir, "b.go"), []byte(""), 0644) - output := executeTool("TOOL:LIST_FILES:.", dir) + result, _ := ex.Execute(makeToolCall("list_files", `{"path":"."}`)) - if !contains(output, "a.go") || !contains(output, "b.go") { - t.Errorf("Erwartete beide Dateien in Output: %q", output) + if !strings.Contains(result, "a.go") || !strings.Contains(result, "b.go") { + t.Errorf("Dateien fehlen im Ergebnis: %q", result) + } +} + +func TestListFiles_ShowsTrailingSlashForDirs(t *testing.T) { + dir, ex := setupWorkDir(t) + os.MkdirAll(filepath.Join(dir, "subdir"), 0755) + + result, _ := ex.Execute(makeToolCall("list_files", `{"path":"."}`)) + + if !strings.Contains(result, "subdir/") { + t.Errorf("Verzeichnis ohne Slash: %q", result) + } +} + +func TestListFiles_EmptyDir(t *testing.T) { + _, ex := setupWorkDir(t) + + result, _ := ex.Execute(makeToolCall("list_files", `{"path":"."}`)) + + if !strings.Contains(result, "leer") { + t.Errorf("Erwartete 'leer' fΓΌr leeres Verzeichnis: %q", result) } } func TestListFiles_NonExistentDir_ReturnsError(t *testing.T) { - dir := setupWorkDir(t) + _, ex := setupWorkDir(t) - output := executeTool("TOOL:LIST_FILES:gibts-nicht", dir) + result, _ := ex.Execute(makeToolCall("list_files", `{"path":"gibts-nicht"}`)) - if output[:16] != "LIST_FILES ERROR" { - t.Errorf("Erwartete Fehlermeldung, bekam: %q", output) + if !strings.Contains(result, "ERROR") { + t.Errorf("Erwartete ERROR, bekam: %q", result) } } -// ─── UNGÜLTIGE TOOL-CALLS ──────────────────────────────── +// ─── TASK_COMPLETE ──────────────────────────────────────── -func TestExecuteTool_UnknownTool_ReturnsError(t *testing.T) { - dir := setupWorkDir(t) +func TestTaskComplete_ReturnsDone(t *testing.T) { + _, ex := setupWorkDir(t) - output := executeTool("TOOL:UNKNOWN_TOOL:arg", dir) + result, done := ex.Execute(makeToolCall("task_complete", `{}`)) - if output[:5] != "ERROR" { - t.Errorf("Erwartete Fehlermeldung, bekam: %q", output) + if !done { + t.Error("task_complete sollte done=true zurΓΌckgeben") + } + if result == "" { + t.Error("task_complete sollte eine BestΓ€tigung zurΓΌckgeben") } } -func TestExecuteTool_InvalidFormat_ReturnsError(t *testing.T) { - dir := setupWorkDir(t) +// ─── UNBEKANNTES TOOL ───────────────────────────────────── - output := executeTool("TOOL:", dir) +func TestUnknownTool_ReturnsError(t *testing.T) { + _, ex := setupWorkDir(t) - if output[:5] != "ERROR" { - t.Errorf("Erwartete Fehlermeldung, bekam: %q", output) + result, done := ex.Execute(makeToolCall("unknown_tool", `{}`)) + + if done { + t.Error("Sollte done=false zurΓΌckgeben") + } + if !strings.Contains(result, "ERROR") { + t.Errorf("Erwartete ERROR, bekam: %q", result) } } -// ─── EXECUTE_TOOLS (Top-Level) ─────────────────────────── +// ─── SICHERHEIT ─────────────────────────────────────────── -func TestExecuteTools_ParsesMultipleToolCalls(t *testing.T) { - dir := setupWorkDir(t) +func TestWriteFile_BlocksPathTraversal(t *testing.T) { + dir, ex := setupWorkDir(t) - response := `Ich schreibe zwei Dateien: -TOOL:WRITE_FILE:foo.go:package foo -TOOL:WRITE_FILE:bar.go:package bar` + result, _ := ex.Execute(makeToolCall("write_file", + `{"path":"../../etc/passwd","content":"hacked"}`)) - _, hadTools := ExecuteTools(response, dir) - - if !hadTools { - t.Error("HΓ€tte Tool-Calls erkennen sollen") + if !strings.Contains(result, "ERROR") { + t.Errorf("Path Traversal hΓ€tte blockiert werden sollen: %q", result) } - - // Beide Dateien mΓΌssen existieren - for _, name := range []string{"foo.go", "bar.go"} { - if _, err := os.Stat(filepath.Join(dir, name)); os.IsNotExist(err) { - t.Errorf("Datei %s wurde nicht angelegt", name) - } + // Sicherstellen dass die Datei nicht angelegt wurde + if _, err := os.Stat(filepath.Join(dir, "../../etc/passwd")); err == nil { + t.Error("Datei außerhalb workDir wurde angelegt!") } } -func TestExecuteTools_NoToolCalls_ReturnsFalse(t *testing.T) { - dir := setupWorkDir(t) +func TestWriteFile_AbsolutePathInsideWorkDir_IsAllowed(t *testing.T) { + dir, ex := setupWorkDir(t) - _, hadTools := ExecuteTools("Keine Tools hier, nur Text.", dir) + // Absoluter Pfad der innerhalb des workDir liegt + absPath := filepath.Join(dir, "hello.go") + args := `{"path":"` + absPath + `","content":"package main"}` - if hadTools { - t.Error("HΓ€tte keine Tool-Calls erkennen sollen") + result, _ := ex.Execute(makeToolCall("write_file", args)) + + if strings.Contains(result, "ERROR") { + t.Errorf("Absoluter Pfad innerhalb workDir sollte erlaubt sein: %q", result) } } -// ─── Hilfsfunktion ─────────────────────────────────────── +func TestReadFile_BlocksPathTraversal(t *testing.T) { + _, ex := setupWorkDir(t) -func contains(s, substr string) bool { - return len(s) >= len(substr) && (s == substr || - len(s) > 0 && containsHelper(s, substr)) -} + result, _ := ex.Execute(makeToolCall("read_file", + `{"path":"../../etc/passwd"}`)) -func containsHelper(s, substr string) bool { - for i := 0; i <= len(s)-len(substr); i++ { - if s[i:i+len(substr)] == substr { - return true - } + if !strings.Contains(result, "ERROR") { + t.Errorf("Path Traversal hΓ€tte blockiert werden sollen: %q", result) } - return false }