package agent import ( "context" "encoding/json" "fmt" "io" "sort" "strings" "time" "agentu/internal/llm" "agentu/internal/tools" ) const ( defaultMaxToolRounds = 50 doomLoopThreshold = 3 compactRecentMessages = 6 summaryMessageLimit = 8000 contextSummaryPrefix = "[context summary]" summarizerSystemPrompt = "You are a conversation summarizer. Summarize the following conversation history into a concise but detailed summary. Preserve:\n" + "- Key facts, decisions, and conclusions\n" + "- File paths and code changes made\n" + "- Tool call results (especially errors)\n" + "- Current task state and next steps\n\n" + "Do NOT include: tool call syntax, raw JSON, or verbatim file contents.\n" + "Output only the summary, no preamble." ) type Agent struct { provider llm.Provider providerName string model string thinking string thinkingParam string thinkingEnabled bool systemPrompt string toolRegistry *tools.Registry toolTimeout time.Duration maxToolRounds int maxContextTokens int lastUsage *llm.Usage messages []llm.Message } type Options struct { Provider llm.Provider ProviderName string Model string Thinking string ThinkingParam string ThinkingEnabled bool SystemPrompt string ToolRegistry *tools.Registry ToolTimeout time.Duration MaxToolIterations int MaxContextTokens int } func New(opts Options) *Agent { maxToolRounds := opts.MaxToolIterations if maxToolRounds <= 0 { maxToolRounds = defaultMaxToolRounds } toolTimeout := opts.ToolTimeout if toolTimeout <= 0 { toolTimeout = 30 * time.Second } a := &Agent{ provider: opts.Provider, providerName: opts.ProviderName, model: opts.Model, thinking: opts.Thinking, thinkingParam: opts.ThinkingParam, thinkingEnabled: opts.ThinkingEnabled, systemPrompt: opts.SystemPrompt, toolRegistry: opts.ToolRegistry, toolTimeout: toolTimeout, maxToolRounds: maxToolRounds, maxContextTokens: opts.MaxContextTokens, } a.Clear() return a } type RuntimeOptions struct { Provider llm.Provider ProviderName string Model string Thinking string ThinkingParam string ThinkingEnabled bool MaxContextTokens int } type RuntimeInfo struct { ProviderName string Model string Thinking string ThinkingEnabled bool ContextTokens int MaxContextTokens int } func (a *Agent) SetRuntime(opts RuntimeOptions) { if opts.Provider != nil { a.provider = opts.Provider } if opts.ProviderName != "" { a.providerName = opts.ProviderName } if opts.Model != "" { a.model = opts.Model } a.thinking = opts.Thinking a.thinkingParam = opts.ThinkingParam a.thinkingEnabled = opts.ThinkingEnabled a.maxContextTokens = opts.MaxContextTokens } func (a *Agent) RuntimeInfo() RuntimeInfo { return RuntimeInfo{ ProviderName: a.providerName, Model: a.model, Thinking: a.thinking, ThinkingEnabled: a.thinkingEnabled, ContextTokens: llm.EstimateTokens(a.messages, a.toolDefinitions()), MaxContextTokens: a.maxContextTokens, } } func (a *Agent) Clear() { a.lastUsage = nil a.messages = nil if strings.TrimSpace(a.systemPrompt) != "" { a.messages = append(a.messages, llm.Message{ Role: llm.RoleSystem, Content: a.systemPrompt, }) } } // Messages returns a copy of the current conversation messages. func (a *Agent) Messages() []llm.Message { out := make([]llm.Message, len(a.messages)) copy(out, a.messages) return out } // SetMessages replaces the conversation messages (used for session restore). func (a *Agent) SetMessages(messages []llm.Message) { a.messages = append([]llm.Message(nil), messages...) } // LastUsage returns a copy of the most recent provider token usage data. func (a *Agent) LastUsage() *llm.Usage { if a.lastUsage == nil { return nil } usage := *a.lastUsage return &usage } // SetLastUsage restores the most recent provider token usage data. func (a *Agent) SetLastUsage(usage *llm.Usage) { if usage == nil { a.lastUsage = nil return } copy := *usage a.lastUsage = © } // Compact summarizes older conversation messages while preserving the system // prompt and recent messages intact. func (a *Agent) Compact(ctx context.Context) error { if len(a.messages) < 4 { return nil } if a.provider == nil { return fmt.Errorf("provider is not configured") } prefixEnd := 0 if a.messages[0].Role == llm.RoleSystem { prefixEnd = 1 } if len(a.messages)-prefixEnd <= compactRecentMessages { return nil } recentStart := len(a.messages) - compactRecentMessages if recentStart <= prefixEnd { return nil } middle := append([]llm.Message(nil), a.messages[prefixEnd:recentStart]...) if len(middle) == 0 { return nil } summary, usage, err := a.summarizeMessages(ctx, middle) if err != nil { return fmt.Errorf("compact context: %w", err) } summary = strings.TrimSpace(summary) if summary == "" { return fmt.Errorf("compact context: summary was empty") } compressed := make([]llm.Message, 0, prefixEnd+1+len(a.messages[recentStart:])) compressed = append(compressed, a.messages[:prefixEnd]...) compressed = append(compressed, llm.Message{ Role: llm.RoleAssistant, Content: contextSummaryPrefix + "\n" + summary, }) compressed = append(compressed, a.messages[recentStart:]...) a.messages = compressed if usage != nil { a.lastUsage = usage } return nil } func (a *Agent) shouldCompact() bool { if a.maxContextTokens <= 0 { return false } if a.lastUsage != nil && a.lastUsage.TotalTokens >= a.maxContextTokens { return true } return llm.EstimateTokens(a.messages, a.toolDefinitions()) >= a.maxContextTokens } func (a *Agent) summarizeMessages(ctx context.Context, messages []llm.Message) (string, *llm.Usage, error) { req := llm.ChatRequest{ Model: a.model, Messages: []llm.Message{ {Role: llm.RoleSystem, Content: summarizerSystemPrompt}, {Role: llm.RoleUser, Content: formatMessagesForSummary(messages)}, }, } var summary strings.Builder var usage *llm.Usage if err := a.provider.ChatStream(ctx, req, func(event llm.StreamEvent) error { if event.Content != "" { summary.WriteString(event.Content) } if event.Usage != nil { copy := *event.Usage usage = © } return nil }); err != nil { return "", nil, err } return strings.TrimSpace(summary.String()), usage, nil } func formatMessagesForSummary(messages []llm.Message) string { var b strings.Builder b.WriteString("Conversation history to summarize:\n\n") for index, msg := range messages { fmt.Fprintf(&b, "Message %d (%s):\n", index+1, msg.Role) if msg.ToolCallID != "" { fmt.Fprintf(&b, "tool_call_id: %s\n", msg.ToolCallID) } if msg.Content != "" { fmt.Fprintf(&b, "%s\n", compact(msg.Content, summaryMessageLimit)) } for _, call := range msg.ToolCalls { fmt.Fprintf(&b, "tool_call: %s %s\n", call.Function.Name, compact(call.Function.Arguments, summaryMessageLimit)) } b.WriteByte('\n') } return b.String() } // roundSignature returns a deterministic signature for a set of tool calls // in a single round, used for doom loop detection. func roundSignature(calls []llm.ToolCall) string { var b strings.Builder for _, c := range calls { b.WriteString(c.Function.Name) b.WriteByte('\x00') b.WriteString(strings.TrimSpace(c.Function.Arguments)) b.WriteByte('\x00') } return b.String() } func (a *Agent) RunTurn(ctx context.Context, input string, out io.Writer, logs io.Writer) error { input = strings.TrimSpace(input) if input == "" { return nil } a.messages = append(a.messages, llm.Message{Role: llm.RoleUser, Content: input}) if a.shouldCompact() { if err := a.Compact(ctx); err != nil { if logs != nil { fmt.Fprintf(logs, "[compact] warning: %v\n", err) } } } var recentSignatures []string for round := 0; round < a.maxToolRounds; round++ { content, calls, err := a.streamAssistant(ctx, out) if err != nil { return err } if len(calls) == 0 { a.messages = append(a.messages, llm.Message{ Role: llm.RoleAssistant, Content: content, }) return nil } a.messages = append(a.messages, llm.Message{ Role: llm.RoleAssistant, Content: content, ToolCalls: calls, }) if content != "" { fmt.Fprintln(out) } // Doom loop detection: check if the last N rounds all called // the exact same tools with the exact same arguments. sig := roundSignature(calls) recentSignatures = append(recentSignatures, sig) if len(recentSignatures) > doomLoopThreshold { recentSignatures = recentSignatures[len(recentSignatures)-doomLoopThreshold:] } if len(recentSignatures) == doomLoopThreshold { allSame := true for i := 1; i < len(recentSignatures); i++ { if recentSignatures[i] != recentSignatures[0] { allSame = false break } } if allSame { toolNames := make([]string, 0, len(calls)) for _, c := range calls { toolNames = append(toolNames, c.Function.Name) } msg := fmt.Sprintf( "error: doom loop detected — the same tool call (%s) with identical arguments has been repeated %d times. Stopping. Try a different approach or break the task into smaller steps.", strings.Join(toolNames, ", "), doomLoopThreshold, ) a.messages = append(a.messages, llm.Message{ Role: llm.RoleAssistant, Content: msg, }) return fmt.Errorf("%s", msg) } } for _, call := range calls { result := a.executeTool(ctx, call, logs) a.messages = append(a.messages, llm.Message{ Role: llm.RoleTool, ToolCallID: call.ID, Content: result, }) } } return fmt.Errorf("tool round limit reached after %d rounds; the task may be too complex for a single turn", a.maxToolRounds) } func (a *Agent) streamAssistant(ctx context.Context, out io.Writer) (string, []llm.ToolCall, error) { var content strings.Builder calls := make(map[int]*toolCallBuilder) req := llm.ChatRequest{ Model: a.model, Messages: append([]llm.Message(nil), a.messages...), Tools: a.toolDefinitions(), } if a.thinkingEnabled { thinkingParam := a.thinkingParam if thinkingParam == "" { thinkingParam = "thinking" } req.Extra = map[string]any{thinkingParam: a.thinking} } if len(req.Tools) > 0 { req.ToolChoice = "auto" } err := a.provider.ChatStream(ctx, req, func(event llm.StreamEvent) error { if event.Usage != nil { a.SetLastUsage(event.Usage) } if event.Content != "" { content.WriteString(event.Content) if out != nil { if _, err := io.WriteString(out, event.Content); err != nil { return err } } } for _, delta := range event.ToolCalls { builder := calls[delta.Index] if builder == nil { builder = &toolCallBuilder{index: delta.Index} calls[delta.Index] = builder } builder.apply(delta) } return nil }) if err != nil { return "", nil, err } return content.String(), finalizeToolCalls(calls), nil } func (a *Agent) toolDefinitions() []llm.Tool { if a.toolRegistry == nil { return nil } return a.toolRegistry.Definitions() } func (a *Agent) executeTool(ctx context.Context, call llm.ToolCall, logs io.Writer) string { name := call.Function.Name if logs != nil { fmt.Fprintf(logs, "\n[tool] %s %s\n", name, compact(call.Function.Arguments, 400)) } if a.toolRegistry == nil { return tools.FormatError(fmt.Errorf("tool registry is disabled")) } tool, ok := a.toolRegistry.Get(name) if !ok { return tools.FormatError(fmt.Errorf("unknown tool: %s", name)) } args := strings.TrimSpace(call.Function.Arguments) if args == "" { args = "{}" } toolCtx, cancel := context.WithTimeout(ctx, a.toolTimeout) defer cancel() output, err := tool.Execute(toolCtx, json.RawMessage(args)) if err != nil { if output != "" { return output + "\n" + tools.FormatError(err) } return tools.FormatError(err) } if logs != nil { fmt.Fprintf(logs, "[tool] %s done\n", name) } return output } type toolCallBuilder struct { index int id string callType string name string arguments strings.Builder } func (b *toolCallBuilder) apply(delta llm.ToolCallDelta) { if delta.ID != "" { b.id = delta.ID } if delta.Type != "" { b.callType = delta.Type } if delta.Name != "" { b.name = delta.Name } if delta.Arguments != "" { b.arguments.WriteString(delta.Arguments) } } func finalizeToolCalls(builders map[int]*toolCallBuilder) []llm.ToolCall { if len(builders) == 0 { return nil } indexes := make([]int, 0, len(builders)) for index := range builders { indexes = append(indexes, index) } sort.Ints(indexes) calls := make([]llm.ToolCall, 0, len(indexes)) for _, index := range indexes { builder := builders[index] id := builder.id if id == "" { id = fmt.Sprintf("call_%d", index) } callType := builder.callType if callType == "" { callType = "function" } calls = append(calls, llm.ToolCall{ ID: id, Type: callType, Function: llm.FunctionCall{ Name: builder.name, Arguments: builder.arguments.String(), }, }) } return calls } func compact(value string, limit int) string { value = strings.TrimSpace(value) if len(value) <= limit { return value } return value[:limit] + "...[truncated]" }