mirror of
https://github.com/coder/coder.git
synced 2026-06-03 04:58:23 +00:00
41e15ae440
Replace the 200ms polling loop in chatd's execute and
process_output tools with server-side blocking via sync.Cond
on HeadTailBuffer.
The agent's GET /{id}/output endpoint accepts ?wait=true to
block until the process exits or a 5-minute server cap expires.
The process_output tool blocks by default for 10s (overridable
via wait_timeout), and falls back to a non-blocking snapshot on
timeout. The execute tool's foreground path makes a single
blocking call instead of polling.
Related #23316
327 lines
7.6 KiB
Go
327 lines
7.6 KiB
Go
package agentproc
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
|
)
|
|
|
|
const (
|
|
// MaxHeadBytes is the number of bytes retained from the
|
|
// beginning of the output for LLM consumption.
|
|
MaxHeadBytes = 16 << 10 // 16KB
|
|
|
|
// MaxTailBytes is the number of bytes retained from the
|
|
// end of the output for LLM consumption.
|
|
MaxTailBytes = 16 << 10 // 16KB
|
|
|
|
// MaxLineLength is the maximum length of a single line
|
|
// before it is truncated. This prevents minified files
|
|
// or other long single-line output from consuming the
|
|
// entire buffer.
|
|
MaxLineLength = 2048
|
|
|
|
// lineTruncationSuffix is appended to lines that exceed
|
|
// MaxLineLength.
|
|
lineTruncationSuffix = " ... [truncated]"
|
|
)
|
|
|
|
// HeadTailBuffer is a thread-safe buffer that captures process
|
|
// output and provides head+tail truncation for LLM consumption.
|
|
// It implements io.Writer so it can be used directly as
|
|
// cmd.Stdout or cmd.Stderr.
|
|
//
|
|
// The buffer stores up to MaxHeadBytes from the beginning of
|
|
// the output and up to MaxTailBytes from the end in a ring
|
|
// buffer, keeping total memory usage bounded regardless of
|
|
// how much output is written.
|
|
type HeadTailBuffer struct {
|
|
mu sync.Mutex
|
|
cond *sync.Cond
|
|
head []byte
|
|
tail []byte
|
|
tailPos int
|
|
tailFull bool
|
|
headFull bool
|
|
closed bool
|
|
totalBytes int
|
|
maxHead int
|
|
maxTail int
|
|
}
|
|
|
|
// NewHeadTailBuffer creates a new HeadTailBuffer with the
|
|
// default head and tail sizes.
|
|
func NewHeadTailBuffer() *HeadTailBuffer {
|
|
b := &HeadTailBuffer{
|
|
maxHead: MaxHeadBytes,
|
|
maxTail: MaxTailBytes,
|
|
}
|
|
b.cond = sync.NewCond(&b.mu)
|
|
return b
|
|
}
|
|
|
|
// NewHeadTailBufferSized creates a HeadTailBuffer with custom
|
|
// head and tail sizes. This is useful for testing truncation
|
|
// logic with smaller buffers.
|
|
func NewHeadTailBufferSized(maxHead, maxTail int) *HeadTailBuffer {
|
|
b := &HeadTailBuffer{
|
|
maxHead: maxHead,
|
|
maxTail: maxTail,
|
|
}
|
|
b.cond = sync.NewCond(&b.mu)
|
|
return b
|
|
}
|
|
|
|
// Write implements io.Writer. It is safe for concurrent use.
|
|
// All bytes are accepted; the return value always equals
|
|
// len(p) with a nil error.
|
|
func (b *HeadTailBuffer) Write(p []byte) (int, error) {
|
|
if len(p) == 0 {
|
|
return 0, nil
|
|
}
|
|
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
|
|
n := len(p)
|
|
b.totalBytes += n
|
|
|
|
// Fill head buffer if it is not yet full.
|
|
if !b.headFull {
|
|
remaining := b.maxHead - len(b.head)
|
|
if remaining > 0 {
|
|
take := remaining
|
|
if take > len(p) {
|
|
take = len(p)
|
|
}
|
|
b.head = append(b.head, p[:take]...)
|
|
p = p[take:]
|
|
if len(b.head) >= b.maxHead {
|
|
b.headFull = true
|
|
}
|
|
}
|
|
if len(p) == 0 {
|
|
return n, nil
|
|
}
|
|
}
|
|
|
|
// Write remaining bytes into the tail ring buffer.
|
|
b.writeTail(p)
|
|
return n, nil
|
|
}
|
|
|
|
// writeTail appends data to the tail ring buffer. The caller
|
|
// must hold b.mu.
|
|
func (b *HeadTailBuffer) writeTail(p []byte) {
|
|
if b.maxTail <= 0 {
|
|
return
|
|
}
|
|
|
|
// Lazily allocate the tail buffer on first use.
|
|
if b.tail == nil {
|
|
b.tail = make([]byte, b.maxTail)
|
|
}
|
|
|
|
for len(p) > 0 {
|
|
// Write as many bytes as fit starting at tailPos.
|
|
space := b.maxTail - b.tailPos
|
|
take := space
|
|
if take > len(p) {
|
|
take = len(p)
|
|
}
|
|
copy(b.tail[b.tailPos:b.tailPos+take], p[:take])
|
|
p = p[take:]
|
|
b.tailPos += take
|
|
if b.tailPos >= b.maxTail {
|
|
b.tailPos = 0
|
|
b.tailFull = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// tailBytes returns the current tail contents in order. The
|
|
// caller must hold b.mu.
|
|
func (b *HeadTailBuffer) tailBytes() []byte {
|
|
if b.tail == nil {
|
|
return nil
|
|
}
|
|
if !b.tailFull {
|
|
// Haven't wrapped yet; data is [0, tailPos).
|
|
return b.tail[:b.tailPos]
|
|
}
|
|
// Wrapped: data is [tailPos, maxTail) + [0, tailPos).
|
|
out := make([]byte, b.maxTail)
|
|
n := copy(out, b.tail[b.tailPos:])
|
|
copy(out[n:], b.tail[:b.tailPos])
|
|
return out
|
|
}
|
|
|
|
// Bytes returns a copy of the raw buffer contents. If no
|
|
// truncation has occurred the full output is returned;
|
|
// otherwise the head and tail portions are concatenated.
|
|
func (b *HeadTailBuffer) Bytes() []byte {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
|
|
tail := b.tailBytes()
|
|
if len(tail) == 0 {
|
|
out := make([]byte, len(b.head))
|
|
copy(out, b.head)
|
|
return out
|
|
}
|
|
out := make([]byte, len(b.head)+len(tail))
|
|
copy(out, b.head)
|
|
copy(out[len(b.head):], tail)
|
|
return out
|
|
}
|
|
|
|
// Len returns the number of bytes currently stored in the
|
|
// buffer.
|
|
func (b *HeadTailBuffer) Len() int {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
|
|
tailLen := 0
|
|
if b.tailFull {
|
|
tailLen = b.maxTail
|
|
} else if b.tail != nil {
|
|
tailLen = b.tailPos
|
|
}
|
|
return len(b.head) + tailLen
|
|
}
|
|
|
|
// TotalWritten returns the total number of bytes written to
|
|
// the buffer, which may exceed the stored capacity.
|
|
func (b *HeadTailBuffer) TotalWritten() int {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return b.totalBytes
|
|
}
|
|
|
|
// Output returns the truncated output suitable for LLM
|
|
// consumption, along with truncation metadata. If the total
|
|
// output fits within the head buffer alone, the full output is
|
|
// returned with nil truncation info. Otherwise the head and
|
|
// tail are joined with an omission marker and long lines are
|
|
// truncated.
|
|
func (b *HeadTailBuffer) Output() (string, *workspacesdk.ProcessTruncation) {
|
|
b.mu.Lock()
|
|
head := make([]byte, len(b.head))
|
|
copy(head, b.head)
|
|
tail := b.tailBytes()
|
|
total := b.totalBytes
|
|
headFull := b.headFull
|
|
b.mu.Unlock()
|
|
|
|
storedLen := len(head) + len(tail)
|
|
|
|
// If everything fits, no head/tail split is needed.
|
|
if !headFull || len(tail) == 0 {
|
|
out := truncateLines(string(head))
|
|
if total == 0 {
|
|
return "", nil
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// We have both head and tail data, meaning the total
|
|
// output exceeded the head capacity. Build the
|
|
// combined output with an omission marker.
|
|
omitted := total - storedLen
|
|
headStr := truncateLines(string(head))
|
|
tailStr := truncateLines(string(tail))
|
|
|
|
var sb strings.Builder
|
|
_, _ = sb.WriteString(headStr)
|
|
if omitted > 0 {
|
|
_, _ = sb.WriteString(fmt.Sprintf(
|
|
"\n\n... [omitted %d bytes] ...\n\n",
|
|
omitted,
|
|
))
|
|
} else {
|
|
// Head and tail are contiguous but were stored
|
|
// separately because the head filled up.
|
|
_, _ = sb.WriteString("\n")
|
|
}
|
|
_, _ = sb.WriteString(tailStr)
|
|
result := sb.String()
|
|
|
|
return result, &workspacesdk.ProcessTruncation{
|
|
OriginalBytes: total,
|
|
RetainedBytes: len(result),
|
|
OmittedBytes: omitted,
|
|
Strategy: "head_tail",
|
|
}
|
|
}
|
|
|
|
// truncateLines scans the input line by line and truncates
|
|
// any line longer than MaxLineLength.
|
|
func truncateLines(s string) string {
|
|
if len(s) <= MaxLineLength {
|
|
// Fast path: if the entire string is shorter than
|
|
// the max line length, no line can exceed it.
|
|
return s
|
|
}
|
|
|
|
var b strings.Builder
|
|
b.Grow(len(s))
|
|
|
|
for len(s) > 0 {
|
|
idx := strings.IndexByte(s, '\n')
|
|
var line string
|
|
if idx == -1 {
|
|
line = s
|
|
s = ""
|
|
} else {
|
|
line = s[:idx]
|
|
s = s[idx+1:]
|
|
}
|
|
|
|
if len(line) > MaxLineLength {
|
|
// Truncate preserving the suffix length so the
|
|
// total does not exceed a reasonable size.
|
|
cut := MaxLineLength - len(lineTruncationSuffix)
|
|
if cut < 0 {
|
|
cut = 0
|
|
}
|
|
_, _ = b.WriteString(line[:cut])
|
|
_, _ = b.WriteString(lineTruncationSuffix)
|
|
} else {
|
|
_, _ = b.WriteString(line)
|
|
}
|
|
|
|
// Re-add the newline unless this was the final
|
|
// segment without a trailing newline.
|
|
if idx != -1 {
|
|
_ = b.WriteByte('\n')
|
|
}
|
|
}
|
|
|
|
return b.String()
|
|
}
|
|
|
|
// Close marks the buffer as closed and wakes any waiters.
|
|
// This is called when the process exits.
|
|
func (b *HeadTailBuffer) Close() {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
b.closed = true
|
|
b.cond.Broadcast()
|
|
}
|
|
|
|
// Reset clears the buffer, discarding all data.
|
|
func (b *HeadTailBuffer) Reset() {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
b.head = nil
|
|
b.tail = nil
|
|
b.tailPos = 0
|
|
b.tailFull = false
|
|
b.headFull = false
|
|
b.closed = false
|
|
b.totalBytes = 0
|
|
b.cond.Broadcast()
|
|
}
|