diff --git a/cmd/olliesrv/internal/agent/history.go b/cmd/olliesrv/internal/agent/history.go index 5173e51..0fdbbfb 100644 --- a/cmd/olliesrv/internal/agent/history.go +++ b/cmd/olliesrv/internal/agent/history.go @@ -141,28 +141,6 @@ func (s *History) update(assistant backend.Message, results []toolResult) { } } -// llmSummarizeToolResult uses the LLM to produce a concise summary of a tool result. -func llmSummarizeToolResult(ctx context.Context, b backend.Backend, name, content string) string { - prompt := fmt.Sprintf("Summarize this tool result from %q in 1-3 sentences, preserving key facts, file paths, values, and findings:\n\n%s", name, content) - ch, err := b.ChatStream(ctx, []backend.Message{{Role: "user", Content: prompt}}, nil, backend.GenerationParams{}) - if err != nil { - return content - } - var sb strings.Builder - for ev := range ch { - if ev.Content != "" { - sb.WriteString(ev.Content) - } - if ev.Done { - break - } - } - if s := strings.TrimSpace(sb.String()); s != "" { - return fmt.Sprintf("[summary of %s result]: %s", name, s) - } - return content -} - // removeCancelledToolResults filters out tool results that were cancelled due // to interrupt, keeping completed work. Also removes the corresponding tool // calls from assistant messages to maintain a valid message sequence. @@ -371,6 +349,21 @@ func flattenToolMessages(messages []backend.Message) []backend.Message { return out } +// injectPreamble ensures the preamble system message is at history position 0. +// If a system message already exists there, its Content is replaced; otherwise +// one is prepended. +func (s *History) injectPreamble(preamble string) { + if preamble == "" { + return + } + msg := backend.Message{Role: "system", Content: preamble} + if len(s.messages) > 0 && s.messages[0].Role == "system" { + s.messages[0] = msg + } else { + s.messages = append([]backend.Message{msg}, s.messages...) + } +} + func (s *History) appendUserMessage(content string) { s.messages = append(s.messages, backend.Message{Role: "user", Content: content}) } @@ -417,16 +410,62 @@ func (s *History) estimateTokens() int { } // stripCold summarizes large tool-result messages outside the hot tail -// using an LLM call. Messages in the last hotTailSize slots are left verbatim. +// using a single batched LLM call. Messages in the last hotTailSize slots +// are left verbatim. func (s *History) stripCold(ctx context.Context, b backend.Backend) { hot := len(s.messages) - hotTailSize + if hot < 0 { + return + } + + // Collect cold-zone tool results exceeding 200 chars. + type item struct { + id int + idx int // index into s.messages + orig string + } + var items []item for i := range s.messages { if i >= hot { break } m := &s.messages[i] if m.Role == "tool" && len(m.Content) > 200 { - m.Content = llmSummarizeToolResult(ctx, b, m.ToolCallID, m.Content) + items = append(items, item{id: len(items), idx: i, orig: m.Content}) + } + } + if len(items) == 0 { + return + } + + // Build a single prompt listing each item. + var sb strings.Builder + sb.WriteString("Compress each of the following tool results to 1-3 sentences preserving key facts, file paths, values, and findings.\n\nReturn a JSON object where each key is the id and the value is the summary, for example:\n{\"0\": \"summarized result 0\", \"1\": \"summarized result 1\"}\n\nOnly include keys that were in the input.\n\n") + for _, it := range items { + fmt.Fprintf(&sb, "---\nid=%d:\n%s\n", it.id, it.orig) + } + + ch, err := b.ChatStream(ctx, []backend.Message{{Role: "user", Content: sb.String()}}, nil, backend.GenerationParams{}) + if err != nil { + return + } + var resp strings.Builder + for ev := range ch { + if ev.Content != "" { + resp.WriteString(ev.Content) + } + if ev.Done { + break + } + } + summaries := make(map[string]string) + json.Unmarshal([]byte(resp.String()), &summaries) + + // Apply summaries back into history. + for _, it := range items { + key := fmt.Sprintf("%d", it.id) + if summary, ok := summaries[key]; ok && summary != "" { + s.messages[it.idx].Content = summary } } } diff --git a/cmd/olliesrv/internal/agent/loop.go b/cmd/olliesrv/internal/agent/loop.go index db57535..0a8e2ab 100644 --- a/cmd/olliesrv/internal/agent/loop.go +++ b/cmd/olliesrv/internal/agent/loop.go @@ -126,9 +126,6 @@ func (ag *Agent) run(ctx context.Context) error { } history := h.history() - if preamble := rt.PreambleString(); preamble != "" { - history = append([]backend.Message{{Role: "system", Content: preamble}}, history...) - } responseID := paths.NewUUID() content, reasoning, toolCalls, stopReason, err := ag.streamResponse(ctx, history, responseID, step) diff --git a/cmd/olliesrv/internal/agent/turn.go b/cmd/olliesrv/internal/agent/turn.go index 2c1cc8e..4da4f58 100644 --- a/cmd/olliesrv/internal/agent/turn.go +++ b/cmd/olliesrv/internal/agent/turn.go @@ -197,6 +197,11 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string { ag.history.resetTurnAccumulators() } + // Inject the preamble system message into history before running. + if ag.history != nil { + ag.history.injectPreamble(ag.runtime.PreambleString()) + } + // Run the turn, retrying once after compaction on context overflow. var ( overflowRetried bool @@ -229,6 +234,8 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string { actCtx, actCancel = context.WithCancelCause(ctx) handle = &actionHandle{cancel: actCancel} ag.currentAction.Store(handle) + // Re-inject preamble — compaction strips it from history. + ag.history.injectPreamble(ag.runtime.PreambleString()) continue } break