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

@@ -18,16 +18,10 @@ const (
maxTurns = 10 maxTurns = 10
) )
var systemPrompt = `Du bist ein Coding-Agent und programmierst Go. var systemPrompt = `Du bist ein autonomer Coding-Agent.
Erledige deine Aufgabe mit folgenden Tools: Erledige den gegebenen Task vollständig mit den bereitgestellten Tools.
TOOL:READ_FILE:pfad Rufe task_complete auf sobald der Task erledigt ist.
TOOL:WRITE_FILE:pfad:<<<inhalt>>> Nutze ausschließlich relative Pfade.`
TOOL:LIST_FILES:pfad
REGELN:
- Nutze relative Pfade
- Kein Markdown in Dateiinhalten
- Wenn Task erledigt: schreibe nur TASK_COMPLETE`
type AgentLoop struct { type AgentLoop struct {
client *openai.Client client *openai.Client
@@ -74,6 +68,7 @@ func (a *AgentLoop) Run() error {
a.log.TaskStart(task.Title) a.log.TaskStart(task.Title)
success := false success := false
var lastErr error
for attempt := 1; attempt <= maxRetries; attempt++ { for attempt := 1; attempt <= maxRetries; attempt++ {
if attempt > 1 { if attempt > 1 {
a.log.Info("🔁 Retry %d/%d...", attempt, maxRetries) a.log.Info("🔁 Retry %d/%d...", attempt, maxRetries)
@@ -84,6 +79,7 @@ func (a *AgentLoop) Run() error {
success = true success = true
break break
} else { } else {
lastErr = err
a.log.Info("⚠️ Fehler: %v", err) a.log.Info("⚠️ Fehler: %v", err)
} }
} }
@@ -93,6 +89,7 @@ func (a *AgentLoop) Run() error {
a.log.TaskDone(task.Title) a.log.TaskDone(task.Title)
} else { } else {
a.log.TaskFailed(task.Title, maxRetries) a.log.TaskFailed(task.Title, maxRetries)
_ = lastErr
} }
} }
@@ -101,137 +98,116 @@ func (a *AgentLoop) Run() error {
} }
func (a *AgentLoop) runTask(task prd.Task) error { func (a *AgentLoop) runTask(task prd.Task) error {
executor := NewToolExecutor(a.workDir)
// Frischer Kontext pro Task // Frischer Kontext pro Task
messages := []openai.ChatCompletionMessageParamUnion{ messages := []openai.ChatCompletionMessageParamUnion{
openai.SystemMessage(systemPrompt), openai.SystemMessage(systemPrompt),
openai.UserMessage(fmt.Sprintf( openai.UserMessage(fmt.Sprintf(
"Task: %s\nArbeitsverzeichnis: %s", "Task: %s\nArbeitsverzeichnis: %s",
task.Title, task.Title, a.workDir,
a.workDir,
)), )),
} }
a.log.ChatMessage("system", systemPrompt) a.log.ChatMessage("system", systemPrompt)
a.log.ChatMessage("user", fmt.Sprintf( a.log.ChatMessage("user", fmt.Sprintf(
"Task: %s\nArbeitsverzeichnis: %s", "Task: %s\nArbeitsverzeichnis: %s",
task.Title, task.Title, a.workDir,
a.workDir,
)) ))
for turn := 0; turn < maxTurns; turn++ { for turn := 0; turn < maxTurns; turn++ {
a.log.Turn(turn + 1) a.log.Turn(turn + 1)
// Token-Schätzung für Debugging
totalChars := 0 totalChars := 0
for _, m := range messages { for _, m := range messages {
totalChars += len(fmt.Sprintf("%v", m)) totalChars += len(fmt.Sprintf("%v", m))
} }
start := time.Now() 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( resp, err := a.client.Chat.Completions.New(
context.Background(), ctx,
openai.ChatCompletionNewParams{ openai.ChatCompletionNewParams{
Model: a.model, Model: a.model,
Messages: messages, Messages: messages,
Tools: Tools, // ← Tool Calling
}, },
) )
cancel()
elapsed := time.Since(start) elapsed := time.Since(start)
if resp != nil && len(resp.Choices) > 0 { if resp != nil && len(resp.Choices) > 0 {
a.log.Debug("MODEL RESPONSE\n%s", formatResponse(resp, elapsed)) a.log.Debug("MODEL RESPONSE\n%s", formatResponse(resp, elapsed))
} }
if err != nil { 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 choice := resp.Choices[0]
a.log.ChatMessage("assistant", response)
messages = append(messages, openai.AssistantMessage(response))
// Completion Detection // Antwort zur History hinzufügen
if isTaskComplete(response) { messages = append(messages, choice.Message.ToParam())
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
}
// Tool Execution // Kein Tool-Call → LLM hat nur Text geantwortet
toolOutput, hadTools := ExecuteTools(response, a.workDir) if len(choice.Message.ToolCalls) == 0 {
if hadTools { a.log.ChatMessage("assistant", choice.Message.Content)
a.log.ChatMessage("tool", toolOutput) nudge := "Nutze die bereitgestellten Tools. Rufe task_complete auf wenn du fertig bist."
messages = append(messages, openai.UserMessage(toolOutput)) a.log.ChatMessage("user", nudge)
messages = append(messages, openai.UserMessage(nudge))
continue continue
} }
// Kein Tool, kein TASK_COMPLETE → anstupsen // Tool-Calls ausführen
nudge := "Fahre fort. Wenn der Task erledigt ist, schreibe TASK_COMPLETE." for _, toolCall := range choice.Message.ToolCalls {
a.log.ChatMessage("user", nudge) a.log.Info(" 🔧 %s(%s)", toolCall.Function.Name,
messages = append(messages, openai.UserMessage(nudge)) 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 // Tool-Ergebnis zurück ans LLM
func isTaskComplete(response string) bool { messages = append(messages, openai.ToolMessage(result, toolCall.ID))
if strings.Contains(response, "TASK_COMPLETE") {
return true if done {
} return nil // task_complete aufgerufen → Erfolg
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
} }
} }
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 { func formatMessages(messages []openai.ChatCompletionMessageParamUnion) string {
var sb strings.Builder var sb strings.Builder
for i, m := range messages { for i, m := range messages {
var role, content string var role, content string
switch { switch {
case m.OfSystem != nil: case m.OfSystem != nil:
role = "system" 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: case m.OfUser != nil:
role = "user" 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: case m.OfAssistant != nil:
role = "assistant" role = "assistant"
if len(m.OfAssistant.Content.OfString.Value) > 0 { content = m.OfAssistant.Content.OfString.Value
content = m.OfAssistant.Content.OfString.Value
}
default: default:
role = "unknown" role = "other"
content = fmt.Sprintf("%+v", m)
} }
preview := strings.ReplaceAll(truncate(content, 120), "\n", "↵")
// 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", "↵")
sb.WriteString(fmt.Sprintf(" [%d] %-10s : %s\n", i, role, preview)) sb.WriteString(fmt.Sprintf(" [%d] %-10s : %s\n", i, role, preview))
} }
return sb.String() return sb.String()
@@ -239,7 +215,6 @@ func formatMessages(messages []openai.ChatCompletionMessageParamUnion) string {
func formatResponse(resp *openai.ChatCompletion, elapsed time.Duration) string { func formatResponse(resp *openai.ChatCompletion, elapsed time.Duration) string {
var sb strings.Builder var sb strings.Builder
sb.WriteString(fmt.Sprintf(" ID : %s\n", resp.ID)) sb.WriteString(fmt.Sprintf(" ID : %s\n", resp.ID))
sb.WriteString(fmt.Sprintf(" Modell : %s\n", resp.Model)) sb.WriteString(fmt.Sprintf(" Modell : %s\n", resp.Model))
sb.WriteString(fmt.Sprintf(" Elapsed : %s\n", elapsed.Round(time.Millisecond))) 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, resp.Usage.TotalTokens,
)) ))
// Tokens/Sekunde aus den Timing-Daten (Ollama-spezifisch) // Tool-Calls anzeigen
if timings, ok := resp.JSON.ExtraFields["timings"]; ok { if len(resp.Choices[0].Message.ToolCalls) > 0 {
sb.WriteString(fmt.Sprintf(" Timings : %s\n", timings.Raw())) 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() return sb.String()
} }

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 package agent
import ( import (
"encoding/json"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/openai/openai-go"
) )
// ─── Tool Registry ──────────────────────────────────────── // ─── Tool Definitionen ────────────────────────────────────
type Tool struct { var Tools = []openai.ChatCompletionToolParam{
Name string
Description string
Usage string
}
var Registry = []Tool{
{ {
Name: "READ_FILE", Type: "function",
Description: "Liest den Inhalt einer Datei", Function: openai.FunctionDefinitionParam{
Usage: "TOOL:READ_FILE:pfad/zur/datei", 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", Type: "function",
Description: "Schreibt Inhalt in eine Datei (mehrzeilig möglich)", Function: openai.FunctionDefinitionParam{
Usage: `TOOL:WRITE_FILE:pfad/zur/datei Name: "read_file",
<<< Description: openai.String("Liest den Inhalt einer existierenden Datei"),
dateiinhalt hier 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", Type: "function",
Description: "Listet alle Dateien in einem Verzeichnis", Function: openai.FunctionDefinitionParam{
Usage: "TOOL:LIST_FILES:pfad", 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 ─────────────────────────────────────── // ─── Tool Execution ───────────────────────────────────────
// ExecuteTools parst alle Tool-Calls aus der LLM-Antwort und führt sie aus. type ToolExecutor struct {
// Gibt den kombinierten Output und true zurück wenn mindestens ein Tool aufgerufen wurde. workDir string
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 { func NewToolExecutor(workDir string) *ToolExecutor {
// Sicherheits-Check: Path Traversal verhindern return &ToolExecutor{workDir: workDir}
safePath, err := sanitizePath(workDir, call.path) }
// 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 { 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 { 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 { 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) entries, err := os.ReadDir(absPath)
if err != nil { if err != nil {
return fmt.Sprintf("LIST_FILES ERROR: %v", err) return fmt.Sprintf("ERROR: Verzeichnis nicht lesbar: %v", err)
} }
if len(entries) == 0 { if len(entries) == 0 {
return fmt.Sprintf("LIST_FILES %s: (leer)", displayPath) return fmt.Sprintf("OK: %s ist leer", relPath)
} }
var files []string var files []string
for _, e := range entries { for _, e := range entries {
@@ -206,52 +438,27 @@ func listFiles(absPath, displayPath string) string {
files = append(files, e.Name()) 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 ─────────────────────────────────────────── // ─── Sicherheit ───────────────────────────────────────────
// sanitizePath stellt sicher dass der Pfad innerhalb des workDir bleibt. func (e *ToolExecutor) sanitizePath(relPath string) (string, error) {
// Verhindert Directory Traversal wie ../../etc/passwd // Absolute Pfade vom LLM: relativen Teil extrahieren
func sanitizePath(workDir, relPath string) (string, error) {
// Wenn LLM einen absoluten Pfad schickt → relativen Teil extrahieren
if filepath.IsAbs(relPath) { if filepath.IsAbs(relPath) {
workDirClean := filepath.Clean(workDir) workDirClean := filepath.Clean(e.workDir)
// Prüfen ob der absolute Pfad innerhalb des workDir liegt
if strings.HasPrefix(relPath, workDirClean) { if strings.HasPrefix(relPath, workDirClean) {
// Absoluten Pfad direkt nutzen, kein Join nötig
return filepath.Clean(relPath), nil return filepath.Clean(relPath), nil
} }
// Absoluter Pfad außerhalb workDir → nur Dateiname nehmen
relPath = filepath.Base(relPath) relPath = filepath.Base(relPath)
} }
// Normaler Fall: relativer Pfad abs := filepath.Clean(filepath.Join(e.workDir, relPath))
abs := filepath.Clean(filepath.Join(workDir, relPath)) workDirClean := filepath.Clean(e.workDir)
workDirClean := filepath.Clean(workDir)
if !strings.HasPrefix(abs, workDirClean+string(filepath.Separator)) && if !strings.HasPrefix(abs, workDirClean+string(filepath.Separator)) &&
abs != workDirClean { abs != workDirClean {
return "", fmt.Errorf("Pfad außerhalb des Arbeitsverzeichnisses") return "", fmt.Errorf("Pfad außerhalb des Arbeitsverzeichnisses: %s", relPath)
} }
return abs, nil 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"))
}

View File

@@ -3,48 +3,64 @@ package agent
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"github.com/openai/openai-go"
) )
// Hilfsfunktion: temporäres Arbeitsverzeichnis anlegen // ─── Hilfsfunktionen ─────────────────────────────────────
func setupWorkDir(t *testing.T) string {
func setupWorkDir(t *testing.T) (string, *ToolExecutor) {
t.Helper() t.Helper()
dir, err := os.MkdirTemp("", "agent-tools-test-*") dir, err := os.MkdirTemp("", "agent-tools-test-*")
if err != nil { if err != nil {
t.Fatalf("Konnte temp dir nicht anlegen: %v", err) t.Fatalf("Konnte temp dir nicht anlegen: %v", err)
} }
t.Cleanup(func() { os.RemoveAll(dir) }) // wird nach jedem Test aufgeräumt t.Cleanup(func() { os.RemoveAll(dir) })
return 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) { func TestWriteFile_CreatesFile(t *testing.T) {
dir := setupWorkDir(t) dir, ex := setupWorkDir(t)
toolCall := "TOOL:WRITE_FILE:hello.go:package main\n\nfunc main() {}" result, done := ex.Execute(makeToolCall("write_file",
output := executeTool(toolCall, dir) `{"path":"hello.go","content":"package main"}`))
if output != "WRITE_FILE OK: hello.go geschrieben" { if done {
t.Errorf("Unerwarteter Output: %q", output) t.Error("write_file sollte done=false zurückgeben")
} }
if !strings.Contains(result, "OK") {
// Datei muss wirklich existieren t.Errorf("Erwartete OK im Ergebnis, bekam: %q", result)
path := filepath.Join(dir, "hello.go") }
if _, err := os.Stat(path); os.IsNotExist(err) { if _, err := os.Stat(filepath.Join(dir, "hello.go")); os.IsNotExist(err) {
t.Error("Datei wurde nicht angelegt") t.Error("Datei hello.go wurde nicht erstellt")
} }
} }
func TestWriteFile_CorrectContent(t *testing.T) { func TestWriteFile_CorrectContent(t *testing.T) {
dir := setupWorkDir(t) dir, ex := setupWorkDir(t)
expected := "package main\n\nfunc main() {}" 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 { if err != nil {
t.Fatalf("Datei konnte nicht gelesen werden: %v", err) t.Fatalf("Datei nicht lesbar: %v", err)
} }
if string(content) != expected { if string(content) != expected {
t.Errorf("Inhalt falsch\n erwartet: %q\n bekommen: %q", expected, string(content)) 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) { 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") path := filepath.Join(dir, "subdir", "nested", "file.go")
if _, err := os.Stat(path); os.IsNotExist(err) { 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) { func TestWriteFile_MultilineContent(t *testing.T) {
dir := setupWorkDir(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" { content, err := os.ReadFile(filepath.Join(dir, "main.go"))
t.Errorf("Erwartete Fehlermeldung, bekam: %q", output) 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) { func TestReadFile_ReadsExistingFile(t *testing.T) {
dir := setupWorkDir(t) dir, ex := setupWorkDir(t)
expected := "Hello, World!" os.WriteFile(filepath.Join(dir, "test.txt"), []byte("Hello World"), 0644)
// Datei vorbereiten result, done := ex.Execute(makeToolCall("read_file",
os.WriteFile(filepath.Join(dir, "test.txt"), []byte(expected), 0644) `{"path":"test.txt"}`))
output := executeTool("TOOL:READ_FILE:test.txt", dir) if done {
t.Error("read_file sollte done=false zurückgeben")
if output != "READ_FILE test.txt:\n"+expected { }
t.Errorf("Unerwarteter Output: %q", output) if !strings.Contains(result, "Hello World") {
t.Errorf("Dateiinhalt fehlt im Ergebnis: %q", result)
} }
} }
func TestReadFile_NonExistentFile_ReturnsError(t *testing.T) { 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" { if !strings.Contains(result, "ERROR") {
t.Errorf("Erwartete Fehlermeldung, bekam: %q", output) 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) { result, _ := ex.Execute(makeToolCall("read_file", `{bad}`))
dir := setupWorkDir(t)
// 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, "a.go"), []byte(""), 0644)
os.WriteFile(filepath.Join(dir, "b.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") { if !strings.Contains(result, "a.go") || !strings.Contains(result, "b.go") {
t.Errorf("Erwartete beide Dateien in Output: %q", output) 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) { 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" { if !strings.Contains(result, "ERROR") {
t.Errorf("Erwartete Fehlermeldung, bekam: %q", output) t.Errorf("Erwartete ERROR, bekam: %q", result)
} }
} }
// ─── UNGÜLTIGE TOOL-CALLS ──────────────────────────────── // ─── TASK_COMPLETE ────────────────────────────────────────
func TestExecuteTool_UnknownTool_ReturnsError(t *testing.T) { func TestTaskComplete_ReturnsDone(t *testing.T) {
dir := setupWorkDir(t) _, ex := setupWorkDir(t)
output := executeTool("TOOL:UNKNOWN_TOOL:arg", dir) result, done := ex.Execute(makeToolCall("task_complete", `{}`))
if output[:5] != "ERROR" { if !done {
t.Errorf("Erwartete Fehlermeldung, bekam: %q", output) 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) { // ─── UNBEKANNTES TOOL ─────────────────────────────────────
dir := setupWorkDir(t)
output := executeTool("TOOL:", dir) func TestUnknownTool_ReturnsError(t *testing.T) {
_, ex := setupWorkDir(t)
if output[:5] != "ERROR" { result, done := ex.Execute(makeToolCall("unknown_tool", `{}`))
t.Errorf("Erwartete Fehlermeldung, bekam: %q", output)
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) { func TestWriteFile_BlocksPathTraversal(t *testing.T) {
dir := setupWorkDir(t) dir, ex := setupWorkDir(t)
response := `Ich schreibe zwei Dateien: result, _ := ex.Execute(makeToolCall("write_file",
TOOL:WRITE_FILE:foo.go:package foo `{"path":"../../etc/passwd","content":"hacked"}`))
TOOL:WRITE_FILE:bar.go:package bar`
_, hadTools := ExecuteTools(response, dir) if !strings.Contains(result, "ERROR") {
t.Errorf("Path Traversal hätte blockiert werden sollen: %q", result)
if !hadTools {
t.Error("Hätte Tool-Calls erkennen sollen")
} }
// Sicherstellen dass die Datei nicht angelegt wurde
// Beide Dateien müssen existieren if _, err := os.Stat(filepath.Join(dir, "../../etc/passwd")); err == nil {
for _, name := range []string{"foo.go", "bar.go"} { t.Error("Datei außerhalb workDir wurde angelegt!")
if _, err := os.Stat(filepath.Join(dir, name)); os.IsNotExist(err) {
t.Errorf("Datei %s wurde nicht angelegt", name)
}
} }
} }
func TestExecuteTools_NoToolCalls_ReturnsFalse(t *testing.T) { func TestWriteFile_AbsolutePathInsideWorkDir_IsAllowed(t *testing.T) {
dir := setupWorkDir(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 { result, _ := ex.Execute(makeToolCall("write_file", args))
t.Error("Hätte keine Tool-Calls erkennen sollen")
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 { result, _ := ex.Execute(makeToolCall("read_file",
return len(s) >= len(substr) && (s == substr || `{"path":"../../etc/passwd"}`))
len(s) > 0 && containsHelper(s, substr))
}
func containsHelper(s, substr string) bool { if !strings.Contains(result, "ERROR") {
for i := 0; i <= len(s)-len(substr); i++ { t.Errorf("Path Traversal hätte blockiert werden sollen: %q", result)
if s[i:i+len(substr)] == substr {
return true
}
} }
return false
} }