package agent
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/openai/openai-go"
oaioption "github.com/openai/openai-go/option"
"llm-agent/prd"
)
const (
baseURL = "http://127.0.0.1:12434/v1"
maxRetries = 3
maxTurns = 10
)
var systemPrompt = `Du bist ein autonomer Coding-Agent.
Erledige den gegebenen Task vollstΓ€ndig mit den bereitgestellten Tools.
Rufe task_complete auf sobald der Task erledigt ist.
Nutze ausschlieΓlich relative Pfade.`
type AgentLoop struct {
client *openai.Client
model string
workDir string
prdFile string
log *Logger
}
func NewAgentLoop(model, workDir, prdFile string, verbose bool) *AgentLoop {
client := openai.NewClient(
oaioption.WithBaseURL(baseURL),
oaioption.WithAPIKey("ollama"),
)
return &AgentLoop{
client: &client,
model: model,
workDir: workDir,
prdFile: prdFile,
log: NewLogger(verbose),
}
}
func (a *AgentLoop) Run() error {
tasks, err := prd.ParseTasks(a.prdFile)
if err != nil {
return fmt.Errorf("PRD lesen fehlgeschlagen: %w", err)
}
pending := 0
for _, t := range tasks {
if !t.Completed {
pending++
}
}
a.log.Info("π %d Tasks gefunden, %d offen", len(tasks), pending)
for _, task := range tasks {
if task.Completed {
a.log.Info("β
Γberspringe (bereits erledigt): %s", task.Title)
continue
}
a.log.TaskStart(task.Title)
success := false
var lastErr error
for attempt := 1; attempt <= maxRetries; attempt++ {
if attempt > 1 {
a.log.Info("π Retry %d/%d...", attempt, maxRetries)
time.Sleep(time.Duration(attempt) * 2 * time.Second)
}
if err := a.runTask(task); err == nil {
success = true
break
} else {
lastErr = err
a.log.Info("β οΈ Fehler: %v", err)
}
}
if success {
prd.MarkTaskComplete(a.prdFile, task.Title)
a.log.TaskDone(task.Title)
} else {
a.log.TaskFailed(task.Title, maxRetries)
_ = lastErr
}
}
a.log.Info("\nπ Alle Tasks abgearbeitet!")
return nil
}
func (a *AgentLoop) runTask(task prd.Task) error {
executor := NewToolExecutor(a.workDir)
messages := []openai.ChatCompletionMessageParamUnion{
openai.SystemMessage(systemPrompt),
openai.UserMessage(fmt.Sprintf(
"Task: %s\nArbeitsverzeichnis: %s",
task.Title, a.workDir,
)),
}
a.log.ChatMessage("system", systemPrompt)
a.log.ChatMessage("user", fmt.Sprintf(
"Task: %s\nArbeitsverzeichnis: %s",
task.Title, a.workDir,
))
for turn := 0; turn < maxTurns; turn++ {
a.log.Turn(turn + 1)
totalChars := 0
for _, m := range messages {
totalChars += len(fmt.Sprintf("%v", m))
}
start := time.Now()
a.log.Debug("MODEL REQUEST: model=%s ~%d Zeichen\n%s",
a.model, totalChars, formatMessages(messages))
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
resp, err := a.client.Chat.Completions.New(
ctx,
openai.ChatCompletionNewParams{
Model: a.model,
Messages: messages,
Tools: Tools,
},
)
cancel()
elapsed := time.Since(start)
if resp != nil && len(resp.Choices) > 0 {
a.log.Debug("MODEL RESPONSE\n%s", formatResponse(resp, elapsed))
}
if err != nil {
return fmt.Errorf("API-Fehler (~%d Zeichen): %w", totalChars, err)
}
choice := resp.Choices[0]
messages = append(messages, choice.Message.ToParam())
// Echte Tool-Calls vom SDK
toolCalls := choice.Message.ToolCalls
// Fallback: XML-Format parsen wenn Modell kein natives Tool Calling nutzt
if len(toolCalls) == 0 && strings.Contains(choice.Message.Content, "
// hello.go
// package main...
//
func parseXMLToolCalls(content string) []openai.ChatCompletionMessageToolCall {
var calls []openai.ChatCompletionMessageToolCall
remaining := content
callID := 0
for {
// Funktionsname extrahieren
start := strings.Index(remaining, "")
if nameEnd == -1 {
break
}
funcName := strings.TrimSpace(remaining[nameStart : nameStart+nameEnd])
// Block bis extrahieren
blockEnd := strings.Index(remaining, "")
if blockEnd == -1 {
break
}
block := remaining[start : blockEnd+len("")]
// Parameter extrahieren und als JSON serialisieren
params := extractXMLParams(block)
argsJSON, err := json.Marshal(params)
if err != nil {
remaining = remaining[blockEnd+len(""):]
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(""):]
}
return calls
}
// extractXMLParams extrahiert alle value aus einem Block
func extractXMLParams(block string) map[string]string {
params := make(map[string]string)
remaining := block
for {
start := strings.Index(remaining, "")
if keyEnd == -1 {
break
}
key := strings.TrimSpace(remaining[keyStart : keyStart+keyEnd])
// Value extrahieren
valueStart := keyStart + keyEnd + 1
closeTag := ""
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()
}