summaryrefslogtreecommitdiff
path: root/internal/executor/summary.go
blob: bcf5cfd0dadd69d5c26bd7454b4b45ecd1e1f02a (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
package executor

import (
	"bufio"
	"context"
	"encoding/json"
	"io"
	"os"
	"strings"
	"time"

	"github.com/thepeterstone/claudomator/internal/llm"
)

// synthesizeSummaryMaxBytes caps how much of the stdout log we send to the
// LLM. Larger values cost more tokens with diminishing returns for a 2-4
// sentence summary.
const synthesizeSummaryMaxBytes = 16 * 1024

// synthesizeSummaryTimeout caps the LLM call so a slow local model can't
// stall executor finalization. On timeout, we return "" (the existing
// no-summary path takes over).
const synthesizeSummaryTimeout = 6 * time.Second

// extractSummary reads a stream-json stdout log and returns the text following
// the last "## Summary" heading found in any assistant text block.
// Returns empty string if the file cannot be read or no summary is found.
func extractSummary(stdoutPath string) string {
	f, err := os.Open(stdoutPath)
	if err != nil {
		return ""
	}
	defer f.Close()

	var last string
	scanner := bufio.NewScanner(f)
	scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
	for scanner.Scan() {
		if text := summaryFromLine(scanner.Bytes()); text != "" {
			last = text
		}
	}
	return last
}

// synthesizeSummary asks the LLM to summarize the assistant text content in
// stdoutPath when no "## Summary" heading was present. Returns "" on any
// error, an empty file, or an empty model response — preserving the
// existing "no summary" behavior so the new path is purely additive.
func synthesizeSummary(parent context.Context, c *llm.Client, stdoutPath string) string {
	if c == nil || stdoutPath == "" {
		return ""
	}
	text := readAssistantTextTail(stdoutPath, synthesizeSummaryMaxBytes)
	if strings.TrimSpace(text) == "" {
		return ""
	}

	cctx, cancel := context.WithTimeout(parent, synthesizeSummaryTimeout)
	defer cancel()
	resp, err := c.Chat(cctx, llm.ChatRequest{
		Messages: []llm.Message{
			{Role: "system", Content: "You summarize what an automated coding agent did. Reply with 2-4 sentences of plain prose. No bullets, no headings, no preamble."},
			{Role: "user", Content: "Here is the agent's output. Summarize what it accomplished:\n\n" + text},
		},
	})
	if err != nil {
		return ""
	}
	return strings.TrimSpace(resp.Content)
}

// readAssistantTextTail returns the concatenated `text` blocks from assistant
// stream-json events in the last maxBytes of the file. Non-assistant events
// (system, result, tool_use, etc.) are skipped so the LLM sees just what the
// agent said. Returns "" on any error.
func readAssistantTextTail(stdoutPath string, maxBytes int64) string {
	f, err := os.Open(stdoutPath)
	if err != nil {
		return ""
	}
	defer f.Close()

	stat, err := f.Stat()
	if err != nil {
		return ""
	}
	size := stat.Size()
	if size > maxBytes {
		if _, err := f.Seek(size-maxBytes, io.SeekStart); err != nil {
			return ""
		}
	}

	var sb strings.Builder
	scanner := bufio.NewScanner(f)
	scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
	first := size > maxBytes // if we seeked, drop the first (likely partial) line
	for scanner.Scan() {
		if first {
			first = false
			continue
		}
		var event struct {
			Type    string `json:"type"`
			Message struct {
				Content []struct {
					Type string `json:"type"`
					Text string `json:"text"`
				} `json:"content"`
			} `json:"message"`
		}
		if err := json.Unmarshal(scanner.Bytes(), &event); err != nil || event.Type != "assistant" {
			continue
		}
		for _, block := range event.Message.Content {
			if block.Type == "text" && block.Text != "" {
				sb.WriteString(block.Text)
				sb.WriteString("\n")
			}
		}
	}
	return sb.String()
}

// summaryFromLine parses a single stream-json line and returns the text after
// "## Summary" if the line is an assistant text block containing that heading.
func summaryFromLine(line []byte) string {
	var event struct {
		Type    string `json:"type"`
		Message struct {
			Content []struct {
				Type string `json:"type"`
				Text string `json:"text"`
			} `json:"content"`
		} `json:"message"`
	}
	if err := json.Unmarshal(line, &event); err != nil || event.Type != "assistant" {
		return ""
	}
	for _, block := range event.Message.Content {
		if block.Type != "text" {
			continue
		}
		idx := strings.Index(block.Text, "## Summary")
		if idx == -1 {
			continue
		}
		return strings.TrimSpace(block.Text[idx+len("## Summary"):])
	}
	return ""
}