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