fix: batch preamble injection and stripCold into single LLM calls

- Remove per-step preamble prepending in loop.go; inject once at turn start
  in turn.go instead of re-sending thousands of tokens every tool step.
- Re-inject preamble after overflow retry compaction where it was lost.
- Replace stripCold() N sequential LLM calls with one batched call that
  summarizes all cold-zone tool results via a single JSON response.
This commit is contained in:
Ollie Agent 2026-08-15 11:16:49 +02:00
parent cca3d9a3f2
commit e0f846bd31
3 changed files with 70 additions and 27 deletions

View File

@ -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
}
}
}

View File

@ -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)

View File

@ -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