summaryrefslogtreecommitdiff
path: root/internal/executor/summary.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/executor/summary.go')
-rw-r--r--internal/executor/summary.go95
1 files changed, 95 insertions, 0 deletions
diff --git a/internal/executor/summary.go b/internal/executor/summary.go
index a942de0..bcf5cfd 100644
--- a/internal/executor/summary.go
+++ b/internal/executor/summary.go
@@ -2,11 +2,26 @@ 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.
@@ -28,6 +43,86 @@ func extractSummary(stdoutPath string) string {
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 {