Compare commits
10 Commits
5c08023fae
...
d94aceb6dc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d94aceb6dc | ||
|
|
1749cbc403 | ||
|
|
00604e04ed | ||
|
|
3b7884b8bd | ||
|
|
7266ffc562 | ||
|
|
076ab0d0c0 | ||
|
|
c26ccce817 | ||
|
|
f25bd1b72d | ||
|
|
c43257b7be | ||
|
|
2f255221c6 |
14
.vscode/launch.json
vendored
14
.vscode/launch.json
vendored
@@ -4,21 +4,15 @@
|
|||||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
{
|
|
||||||
"name": "Launch file",
|
|
||||||
"type": "go",
|
|
||||||
"request": "launch",
|
|
||||||
"mode": "debug",
|
|
||||||
"program": "${file}",
|
|
||||||
"args": ["--prd", "PRD.md", "--verbose", "--workdir", "./output"]
|
|
||||||
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "Launch Package",
|
"name": "Launch Package",
|
||||||
"type": "go",
|
"type": "go",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"mode": "auto",
|
"mode": "auto",
|
||||||
"program": "${fileDirname}"
|
"program": "${fileDirname}",
|
||||||
|
"console": "integratedTerminal",
|
||||||
|
"args": ["--prd", "PRD.md", "--verbose", "--workdir", "./output"]
|
||||||
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
229
CLAUDE.md
Normal file
229
CLAUDE.md
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
# goralphy
|
||||||
|
|
||||||
|
## Projekt
|
||||||
|
Autonomer Go Coding-Agent ähnlich wie Ralphy.
|
||||||
|
Nutzt lokale LLMs via OpenAI-kompatibler API (Docker AI, Port 12434).
|
||||||
|
Die lokalen LLMs sind klein und laufen auf einem Rechner mit 16GB RAM –
|
||||||
|
daher ist die Planung und Task-Aufteilung in atomare Schritte besonders wichtig.
|
||||||
|
|
||||||
|
Das Projekt unterstützt mehrere spezialisierte Agenten die zusammenarbeiten:
|
||||||
|
- **Planner-Agent**: Zerlegt PRD.md in atomare, ausführbare Tasks
|
||||||
|
- **Code-Agent**: Führt Tasks aus, schreibt Code, dokumentiert
|
||||||
|
- **Test-Agent**: Führt Tests aus, analysiert Fehler, gibt Feedback an Code-Agent
|
||||||
|
- **Research-Agent**: Recherchiert Fragen basierend auf konfigurierbaren Quellen
|
||||||
|
(GitHub, Google, Reddit, StackOverflow)
|
||||||
|
|
||||||
|
Jeder Agent dokumentiert Erkenntnisse in `.agent-session.md` damit Fortschritte
|
||||||
|
nachvollziehbar sind und Agenten voneinander lernen können.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Aktueller Implementierungsstand
|
||||||
|
|
||||||
|
### Fertig ✅
|
||||||
|
- `main.go` – Flag-Parsing, Modell-Auswahl via /v1/models API
|
||||||
|
- `agent/loop.go` – Worker-Loop mit Tool Calling + XML-Fallback Parser
|
||||||
|
- `agent/tools.go` – ToolExecutor: write_file, read_file, list_files (rekursiv), task_complete
|
||||||
|
- `agent/logger.go` – Verbose-Logging mit --verbose Flag
|
||||||
|
- `agent/session.go` – Session-Persistenz in .agent-session.md
|
||||||
|
- `prd/parser.go` – PRD.md Checkbox-Parser + MarkTaskComplete
|
||||||
|
|
||||||
|
### In Arbeit 🔄
|
||||||
|
- `agent/planner.go` – Planner-Agent (Grundgerüst vorhanden, noch nicht in loop.go integriert)
|
||||||
|
|
||||||
|
### Geplant 📋
|
||||||
|
- `agent/code.go` – Code-Agent
|
||||||
|
- `agent/test.go` – Test-Agent
|
||||||
|
- `agent/research.go` – Research-Agent
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
|
||||||
|
### Dateien
|
||||||
|
- `main.go` – Einstiegspunkt, Flag-Parsing, Modell-Auswahl
|
||||||
|
- `agent/loop.go` – Haupt-Loop, koordiniert alle Agenten
|
||||||
|
- `agent/tools.go` – Tool-Executor (Filesystem-Operationen)
|
||||||
|
- `agent/logger.go` – Strukturiertes Logging
|
||||||
|
- `agent/session.go` – Session-Persistenz
|
||||||
|
- `agent/planner.go` – Planner-Agent
|
||||||
|
- `agent/code.go` – Code-Agent (geplant)
|
||||||
|
- `agent/test.go` – Test-Agent (geplant)
|
||||||
|
- `agent/research.go` – Research-Agent (geplant)
|
||||||
|
- `prd/parser.go` – PRD.md Parser
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Agent Interface
|
||||||
|
|
||||||
|
Jeder Agent implementiert dieses Interface:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Agent interface {
|
||||||
|
Run(task Task) (Result, error)
|
||||||
|
Name() string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Task struct {
|
||||||
|
ID int
|
||||||
|
Title string
|
||||||
|
Description string
|
||||||
|
OriginTask string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Result struct {
|
||||||
|
Success bool
|
||||||
|
Output string
|
||||||
|
Findings string // wird in .agent-session.md dokumentiert
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Jeder Agent bekommt:
|
||||||
|
- Einen `*openai.Client` für LLM-Calls
|
||||||
|
- Einen `*ToolExecutor` für Filesystem-Zugriff
|
||||||
|
- Einen `*Logger` für strukturiertes Logging
|
||||||
|
- Einen `*Session` für Persistenz
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wichtige Konventionen
|
||||||
|
|
||||||
|
- **Go Modulname**: `llm-agent`
|
||||||
|
- **LLM Endpoint**: `http://127.0.0.1:12434/v1`
|
||||||
|
- **Default Modell**: `docker.io/ai/qwen3-coder:latest`
|
||||||
|
- **Arbeitsverzeichnis**: `./output` (per `--workdir` Flag)
|
||||||
|
- **PRD-Datei**: `PRD.md` (per `--prd` Flag)
|
||||||
|
- **Session-Datei**: `{workdir}/.agent-session.md`
|
||||||
|
- **Pfade**: immer relativ zum workdir, niemals absolut
|
||||||
|
- **Kontext**: frischer Kontext pro Task (kein Context-Bloat)
|
||||||
|
- **Fehlerbehandlung**: maxRetries=3 mit exponential Backoff
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bekannte Probleme & Workarounds
|
||||||
|
|
||||||
|
| Problem | Workaround |
|
||||||
|
|---|---|
|
||||||
|
| Modell nutzt XML-Format statt JSON Tool Calling | XML-Fallback Parser in `loop.go` (`parseXMLToolCalls`) |
|
||||||
|
| Kleine Modelle (3B) halten sich nicht an Formate | Mindestens 7B Modell empfohlen |
|
||||||
|
| `go run` zeigt auf `/tmp` | `os.Getwd()` statt `os.Executable()` |
|
||||||
|
| LLM schreibt absoluten Pfad | `sanitizePath()` extrahiert relativen Teil |
|
||||||
|
| `TASK_COMPLETE` ohne Arbeit in Turn 0 | Guard in `runTask()` – prüft ob Tool-Call stattfand |
|
||||||
|
| API Timeout bei großen Modellen | `context.WithTimeout` auf 5 Minuten |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Bauen
|
||||||
|
go build -o llm-agent .
|
||||||
|
|
||||||
|
# Starten
|
||||||
|
go run main.go --workdir output --prd PRD.md
|
||||||
|
go run main.go --workdir output --prd PRD.md --verbose
|
||||||
|
go run main.go --workdir output --prd PRD.md --verbose --model docker.io/ai/qwen3-coder:latest
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
go test ./...
|
||||||
|
go test -v ./agent/...
|
||||||
|
go test -cover ./agent/...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vorgehensweise für Claude Code
|
||||||
|
|
||||||
|
1. Verfeinere Agent-Interface und Basis-Architektur
|
||||||
|
2. Integriere `planner.go` in `loop.go`
|
||||||
|
3. Implementiere `code.go` (Code-Agent)
|
||||||
|
4. Teste Code-Agent mit einfachen Tasks
|
||||||
|
5. Implementiere `test.go` (Test-Agent)
|
||||||
|
6. Verbinde Code-Agent und Test-Agent in Feedback-Loop
|
||||||
|
7. Implementiere `research.go` (Research-Agent)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Spätere Erweiterungen
|
||||||
|
|
||||||
|
- **Vektor-Datenbank**: Speicherung von Agenten-Erkenntnissen und Code-Snippets
|
||||||
|
für Wiederverwendbarkeit und Lernen zwischen Sessions
|
||||||
|
- **Git-Integration**: Automatische Commits pro Task, Steuerung via Pull Requests,
|
||||||
|
Zusammenarbeit mehrerer Agenten auf separaten Branches
|
||||||
|
- **Discord-Integration**: Echtzeit-Monitoring und Interaktion mit Agenten
|
||||||
|
über Discord Chat
|
||||||
|
- **Streaming**: LLM-Antworten Wort für Wort ausgeben statt auf einmal warten
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session-Format (.agent-session.md)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Agent Session
|
||||||
|
|
||||||
|
Started: 2026-03-04 20:00:00
|
||||||
|
Model: docker.io/ai/qwen3-coder:latest
|
||||||
|
PRD: output/PRD.md
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
### Erstelle go.mod[1]
|
||||||
|
Origin: Projektstruktur anlegen
|
||||||
|
Description: Erstelle go.mod mit Modulname llm-agent...
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
### ✅ Erstelle go.mod
|
||||||
|
Status: complete
|
||||||
|
Started: 2026-03-04 20:00:01
|
||||||
|
Completed: 2026-03-04 20:00:45
|
||||||
|
|
||||||
|
### ❌ Erstelle main.go
|
||||||
|
Status: failed
|
||||||
|
Attempts: 3
|
||||||
|
Error: maximale Turns (10) erreicht ohne task_complete
|
||||||
|
|
||||||
|
### ⏳ Erstelle README.md
|
||||||
|
Status: pending
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tool-Übersicht
|
||||||
|
|
||||||
|
## Tool-Übersicht
|
||||||
|
|
||||||
|
| Tool | Parameter | Beschreibung |
|
||||||
|
|---|---|---|
|
||||||
|
| `write_file` | `path`, `content` | Datei schreiben, Verzeichnisse werden automatisch erstellt |
|
||||||
|
| `read_file` | `path` | Dateiinhalt lesen |
|
||||||
|
| `list_files` | `path`, `recursive?` | Verzeichnis auflisten, optional rekursiv |
|
||||||
|
| `delete_file` | `path` | Datei löschen |
|
||||||
|
| `move_file` | `from`, `to` | Datei verschieben oder umbenennen |
|
||||||
|
| `run_command` | `command`, `args[]`, `workdir?` | Beliebigen Shell-Befehl ausführen (z.B. `go build`, `go test`) |
|
||||||
|
| `run_tests` | `path?`, `flags[]?` | `go test ./...` ausführen, gibt Ergebnis und Fehler zurück |
|
||||||
|
| `git_command` | `args[]` | Beliebigen git-Befehl ausführen (z.B. `["commit", "-m", "msg"]`) |
|
||||||
|
| `git_status` | – | `git status` – zeigt geänderte Dateien |
|
||||||
|
| `git_diff` | `path?` | `git diff` – zeigt Änderungen |
|
||||||
|
| `git_commit` | `message`, `files[]?` | Dateien stagen und committen |
|
||||||
|
| `git_log` | `limit?` | Letzte Commits anzeigen |
|
||||||
|
| `web_search` | `query`, `sources[]?` | Websuche, optionale Quellen: `github`, `stackoverflow`, `reddit`, `google` |
|
||||||
|
| `fetch_url` | `url` | Inhalt einer URL abrufen und als Text zurückgeben |
|
||||||
|
| `task_complete` | `summary?` | Task als erledigt markieren, optionale Zusammenfassung |
|
||||||
|
| `task_failed` | `reason` | Task explizit als fehlgeschlagen markieren mit Begründung |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sicherheitsregeln
|
||||||
|
|
||||||
|
| Tool | Einschränkung |
|
||||||
|
|---|---|
|
||||||
|
| `write_file`, `delete_file`, `move_file` | Nur innerhalb `workdir` |
|
||||||
|
| `run_command` | Konfigurierbare Whitelist erlaubter Befehle |
|
||||||
|
| `git_command` | Nur im Projekt-Root, kein `git push` ohne Bestätigung |
|
||||||
|
| `web_search`, `fetch_url` | Optional deaktivierbar per Flag `--no-network` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementierung
|
||||||
|
|
||||||
7
PRD.md
7
PRD.md
@@ -1,7 +0,0 @@
|
|||||||
# Mein Projekt
|
|
||||||
Ein Starter Projekt in go
|
|
||||||
## Tasks
|
|
||||||
|
|
||||||
- [ ] Erstelle eine Datei hello.go mit einem Hello World Programm
|
|
||||||
- [ ] Erstelle eine Datei README.md mit einer kurzen Projektbeschreibung
|
|
||||||
- [ ] Projektstruktur anlegen
|
|
||||||
271
agent/loop.go
271
agent/loop.go
@@ -2,6 +2,7 @@ package agent
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -18,20 +19,10 @@ const (
|
|||||||
maxTurns = 10
|
maxTurns = 10
|
||||||
)
|
)
|
||||||
|
|
||||||
var systemPrompt = `Du bist ein Coding-Agent. Erledige den gegebenen Task.
|
var systemPrompt = `Du bist ein autonomer Coding-Agent.
|
||||||
|
Erledige den gegebenen Task vollständig mit den bereitgestellten Tools.
|
||||||
TOOLS:
|
Rufe task_complete auf sobald der Task erledigt ist.
|
||||||
TOOL:READ_FILE:pfad
|
Nutze ausschließlich relative Pfade.`
|
||||||
TOOL:WRITE_FILE:pfad
|
|
||||||
<<<
|
|
||||||
inhalt
|
|
||||||
>>>
|
|
||||||
TOOL:LIST_FILES:pfad
|
|
||||||
|
|
||||||
REGELN:
|
|
||||||
- Nutze relative Pfade
|
|
||||||
- Kein Markdown in Dateiinhalten
|
|
||||||
- Task erledigt: schreibe TASK_COMPLETE`
|
|
||||||
|
|
||||||
type AgentLoop struct {
|
type AgentLoop struct {
|
||||||
client *openai.Client
|
client *openai.Client
|
||||||
@@ -78,6 +69,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)
|
||||||
@@ -88,6 +80,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -97,6 +90,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,90 +99,247 @@ func (a *AgentLoop) Run() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *AgentLoop) runTask(task prd.Task) error {
|
func (a *AgentLoop) runTask(task prd.Task) error {
|
||||||
// Frischer Kontext pro Task
|
executor := NewToolExecutor(a.workDir)
|
||||||
|
|
||||||
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()
|
|
||||||
a.log.Debug("MODEL REQUEST: model=%s totalChars=%d messages=%#v", a.model, totalChars, messages)
|
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
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,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
cancel()
|
||||||
|
|
||||||
elapsed := time.Since(start)
|
elapsed := time.Since(start)
|
||||||
a.log.Debug("MODEL RESPONSE (elapsed=%s): %#v", elapsed, resp)
|
if resp != nil && len(resp.Choices) > 0 {
|
||||||
|
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, choice.Message.ToParam())
|
||||||
messages = append(messages, openai.AssistantMessage(response))
|
|
||||||
|
|
||||||
// Completion Detection
|
// Echte Tool-Calls vom SDK
|
||||||
if isTaskComplete(response) {
|
toolCalls := choice.Message.ToolCalls
|
||||||
return nil
|
|
||||||
|
// Fallback: XML-Format parsen wenn Modell kein natives Tool Calling nutzt
|
||||||
|
if len(toolCalls) == 0 && strings.Contains(choice.Message.Content, "<function=") {
|
||||||
|
a.log.Debug("XML-Fallback: Parse Tool-Calls aus Content")
|
||||||
|
toolCalls = parseXMLToolCalls(choice.Message.Content)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tool Execution
|
// Kein Tool-Call → LLM hat nur Text geantwortet
|
||||||
toolOutput, hadTools := ExecuteTools(response, a.workDir)
|
if len(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 toolCalls {
|
||||||
a.log.ChatMessage("user", nudge)
|
a.log.Info(" 🔧 %s(%s)",
|
||||||
messages = append(messages, openai.UserMessage(nudge))
|
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
|
// 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 → 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── XML Fallback Parser ──────────────────────────────────
|
||||||
|
|
||||||
|
// parseXMLToolCalls parst Tool-Calls im XML-Format das manche Modelle nutzen:
|
||||||
|
//
|
||||||
|
// <function=write_file>
|
||||||
|
// <parameter=path>hello.go</parameter>
|
||||||
|
// <parameter=content>package main...</parameter>
|
||||||
|
// </function>
|
||||||
|
func parseXMLToolCalls(content string) []openai.ChatCompletionMessageToolCall {
|
||||||
|
var calls []openai.ChatCompletionMessageToolCall
|
||||||
|
remaining := content
|
||||||
|
callID := 0
|
||||||
|
|
||||||
|
for {
|
||||||
|
// Funktionsname extrahieren
|
||||||
|
start := strings.Index(remaining, "<function=")
|
||||||
|
if start == -1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
nameStart := start + len("<function=")
|
||||||
|
nameEnd := strings.Index(remaining[nameStart:], ">")
|
||||||
|
if nameEnd == -1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
funcName := strings.TrimSpace(remaining[nameStart : nameStart+nameEnd])
|
||||||
|
|
||||||
|
// Block bis </function> extrahieren
|
||||||
|
blockEnd := strings.Index(remaining, "</function>")
|
||||||
|
if blockEnd == -1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
block := remaining[start : blockEnd+len("</function>")]
|
||||||
|
|
||||||
|
// Parameter extrahieren und als JSON serialisieren
|
||||||
|
params := extractXMLParams(block)
|
||||||
|
argsJSON, err := json.Marshal(params)
|
||||||
|
if err != nil {
|
||||||
|
remaining = remaining[blockEnd+len("</function>"):]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
callID++
|
||||||
|
calls = append(calls, openai.ChatCompletionMessageToolCall{
|
||||||
|
ID: fmt.Sprintf("xml-call-%d", callID),
|
||||||
|
Type: "function",
|
||||||
|
Function: openai.ChatCompletionMessageToolCallFunction{
|
||||||
|
Name: funcName,
|
||||||
|
Arguments: string(argsJSON),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
remaining = remaining[blockEnd+len("</function>"):]
|
||||||
|
}
|
||||||
|
|
||||||
|
return calls
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractXMLParams extrahiert alle <parameter=key>value</parameter> aus einem Block
|
||||||
|
func extractXMLParams(block string) map[string]string {
|
||||||
|
params := make(map[string]string)
|
||||||
|
remaining := block
|
||||||
|
|
||||||
|
for {
|
||||||
|
start := strings.Index(remaining, "<parameter=")
|
||||||
|
if start == -1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key extrahieren
|
||||||
|
keyStart := start + len("<parameter=")
|
||||||
|
keyEnd := strings.Index(remaining[keyStart:], ">")
|
||||||
|
if keyEnd == -1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
key := strings.TrimSpace(remaining[keyStart : keyStart+keyEnd])
|
||||||
|
|
||||||
|
// Value extrahieren
|
||||||
|
valueStart := keyStart + keyEnd + 1
|
||||||
|
closeTag := "</parameter>"
|
||||||
|
valueEnd := strings.Index(remaining[valueStart:], closeTag)
|
||||||
|
if valueEnd == -1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
value := strings.TrimSpace(remaining[valueStart : valueStart+valueEnd])
|
||||||
|
|
||||||
|
params[key] = value
|
||||||
|
remaining = remaining[valueStart+valueEnd+len(closeTag):]
|
||||||
|
}
|
||||||
|
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Hilfsfunktionen ─────────────────────────────────────
|
||||||
|
|
||||||
|
func truncate(s string, max int) string {
|
||||||
|
if len(s) <= max {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:max] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
content = m.OfSystem.Content.OfString.Value
|
||||||
|
case m.OfUser != nil:
|
||||||
|
role = "user"
|
||||||
|
content = m.OfUser.Content.OfString.Value
|
||||||
|
case m.OfAssistant != nil:
|
||||||
|
role = "assistant"
|
||||||
|
content = m.OfAssistant.Content.OfString.Value
|
||||||
|
default:
|
||||||
|
role = "other"
|
||||||
|
}
|
||||||
|
preview := strings.ReplaceAll(truncate(content, 120), "\n", "↵")
|
||||||
|
sb.WriteString(fmt.Sprintf(" [%d] %-10s : %s\n", i, role, preview))
|
||||||
|
}
|
||||||
|
return sb.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)))
|
||||||
|
sb.WriteString(fmt.Sprintf(" Finish-Reason : %s\n", resp.Choices[0].FinishReason))
|
||||||
|
sb.WriteString(fmt.Sprintf(" Tokens : prompt=%d completion=%d total=%d\n",
|
||||||
|
resp.Usage.PromptTokens,
|
||||||
|
resp.Usage.CompletionTokens,
|
||||||
|
resp.Usage.TotalTokens,
|
||||||
|
))
|
||||||
|
|
||||||
|
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 if content := resp.Choices[0].Message.Content; content != "" {
|
||||||
|
sb.WriteString(" Content :\n")
|
||||||
|
for _, line := range strings.Split(content, "\n") {
|
||||||
|
sb.WriteString(fmt.Sprintf(" %s\n", line))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|||||||
372
agent/tools.go
372
agent/tools.go
@@ -1,257 +1,251 @@
|
|||||||
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",
|
||||||
|
},
|
||||||
|
"recursive": map[string]any{
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "true = alle Unterverzeichnisse rekursiv auflisten",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"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
|
// ─── Tool Executor ────────────────────────────────────────
|
||||||
func BuildToolPrompt() string {
|
|
||||||
var sb strings.Builder
|
type ToolExecutor struct {
|
||||||
sb.WriteString("Du hast folgende Tools zur Verfügung:\n\n")
|
workDir string
|
||||||
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 ─────────────────────────────────────────
|
func NewToolExecutor(workDir string) *ToolExecutor {
|
||||||
|
return &ToolExecutor{workDir: workDir}
|
||||||
type toolCall struct {
|
|
||||||
name string
|
|
||||||
path string
|
|
||||||
content string // nur für WRITE_FILE
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseToolCalls extrahiert alle Tool-Calls aus einer LLM-Antwort.
|
// Execute führt einen Tool-Call aus.
|
||||||
// Unterstützt mehrzeilige WRITE_FILE Blöcke:
|
// Gibt (result, done) zurück – done=true bedeutet task_complete wurde aufgerufen.
|
||||||
//
|
func (e *ToolExecutor) Execute(toolCall openai.ChatCompletionMessageToolCall) (string, bool) {
|
||||||
// TOOL:WRITE_FILE:pfad
|
name := toolCall.Function.Name
|
||||||
// <<<
|
args := toolCall.Function.Arguments
|
||||||
// inhalt
|
|
||||||
// >>>
|
|
||||||
func parseToolCalls(response string) []toolCall {
|
|
||||||
var calls []toolCall
|
|
||||||
lines := strings.Split(response, "\n")
|
|
||||||
|
|
||||||
i := 0
|
switch name {
|
||||||
for i < len(lines) {
|
case "task_complete":
|
||||||
line := strings.TrimSpace(lines[i])
|
return "Task erfolgreich abgeschlossen.", true
|
||||||
|
|
||||||
if !strings.HasPrefix(line, "TOOL:") {
|
case "write_file":
|
||||||
i++
|
var p struct {
|
||||||
continue
|
Path string `json:"path"`
|
||||||
|
Content string `json:"content"`
|
||||||
}
|
}
|
||||||
|
if err := json.Unmarshal([]byte(args), &p); err != nil {
|
||||||
parts := strings.SplitN(line, ":", 3)
|
return fmt.Sprintf("ERROR: Ungültige Parameter: %v", err), false
|
||||||
if len(parts) < 3 {
|
|
||||||
i++
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
return e.writeFile(p.Path, p.Content), false
|
||||||
|
|
||||||
toolName := parts[1]
|
case "read_file":
|
||||||
toolPath := parts[2]
|
var p struct {
|
||||||
|
Path string `json:"path"`
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
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
|
||||||
|
|
||||||
calls = append(calls, toolCall{
|
case "list_files":
|
||||||
name: toolName,
|
var p struct {
|
||||||
path: toolPath,
|
Path string `json:"path"`
|
||||||
})
|
Recursive bool `json:"recursive"`
|
||||||
i++
|
}
|
||||||
|
if err := json.Unmarshal([]byte(args), &p); err != nil {
|
||||||
|
return fmt.Sprintf("ERROR: Ungültige Parameter: %v", err), false
|
||||||
|
}
|
||||||
|
return e.listFiles(p.Path, p.Recursive), false
|
||||||
}
|
}
|
||||||
|
|
||||||
return calls
|
return fmt.Sprintf("ERROR: Unbekanntes Tool %q", name), false
|
||||||
}
|
}
|
||||||
|
|
||||||
// readContentBlock liest Zeilen zwischen <<< und >>> und gibt den
|
// ─── Implementierungen ────────────────────────────────────
|
||||||
// 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)
|
func (e *ToolExecutor) writeFile(relPath, content string) string {
|
||||||
if i < len(lines) && strings.TrimSpace(lines[i]) == "<<<" {
|
absPath, err := e.sanitizePath(relPath)
|
||||||
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 {
|
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, recursive bool) string {
|
||||||
|
absPath, err := e.sanitizePath(relPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("ERROR: %v", err)
|
||||||
|
}
|
||||||
|
if recursive {
|
||||||
|
return e.listFilesRecursive(absPath, relPath)
|
||||||
|
}
|
||||||
|
return e.listFilesFlat(absPath, relPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ToolExecutor) listFilesFlat(absPath, displayPath string) string {
|
||||||
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", displayPath)
|
||||||
}
|
}
|
||||||
var files []string
|
var files []string
|
||||||
for _, e := range entries {
|
for _, entry := range entries {
|
||||||
if e.IsDir() {
|
if entry.IsDir() {
|
||||||
files = append(files, e.Name()+"/")
|
files = append(files, entry.Name()+"/")
|
||||||
} else {
|
} else {
|
||||||
files = append(files, e.Name())
|
files = append(files, entry.Name())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("LIST_FILES %s:\n%s", displayPath, strings.Join(files, "\n"))
|
return fmt.Sprintf("OK: Dateien in %s:\n%s", displayPath, strings.Join(files, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ToolExecutor) listFilesRecursive(absPath, displayPath string) string {
|
||||||
|
var files []string
|
||||||
|
|
||||||
|
err := filepath.WalkDir(absPath, func(path string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(e.workDir, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if rel == "." {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
files = append(files, rel+"/")
|
||||||
|
} else {
|
||||||
|
files = append(files, rel)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("ERROR: %v", err)
|
||||||
|
}
|
||||||
|
if len(files) == 0 {
|
||||||
|
return fmt.Sprintf("OK: %s ist leer", displayPath)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("OK: Dateien in %s (rekursiv):\n%s",
|
||||||
|
displayPath, 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"))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
7
input/PRD.md
Normal file
7
input/PRD.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Mein Projekt
|
||||||
|
Ein Starter Projekt in go
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [ ] Erstelle eine Datei hello.go die 2 zahlen aus der Kommandozeile einliest und die Summe ausgibt
|
||||||
|
- [ ] Erstelle unit tests für hello.go
|
||||||
|
- [ ] Erstelle eine Datei README.md mit einer kurzen Projektbeschreibung
|
||||||
1
input/README.md
Normal file
1
input/README.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
This folder contains example PRD.md files to be used for testing of the output.
|
||||||
Reference in New Issue
Block a user