Files
goclaw/internal/providers/claude_cli_chat.go
T
Luan Vu 9249d9e358 fix(claude-cli): check scanner.Err() and increase stream buffer to 10MB (#96)
Fixes #94. The stream-json scanner never checked scanner.Err() after the
scan loop, so if a line exceeded the 1MB buffer limit the scanner would
stop with bufio.ErrTooLong and the response would be silently truncated.

- Check scanner.Err() after the loop and return an explicit error
- Increase max buffer from 1MB to 10MB to handle large tool outputs

Co-authored-by: Luvu182 <208665161+Luvu182@users.noreply.github.com>
2026-03-09 15:49:25 +07:00

224 lines
6.0 KiB
Go

package providers
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// Chat runs the CLI synchronously and returns the final response.
func (p *ClaudeCLIProvider) Chat(ctx context.Context, req ChatRequest) (*ChatResponse, error) {
systemPrompt, userMsg, images := extractFromMessages(req.Messages)
sessionKey := extractStringOpt(req.Options, OptSessionKey)
model := req.Model
if model == "" {
model = p.defaultModel
}
if err := validateCLIModel(model); err != nil {
return nil, err
}
unlock := p.lockSession(sessionKey)
defer unlock()
workDir := p.ensureWorkDir(sessionKey)
if systemPrompt != "" {
p.writeClaudeMD(workDir, systemPrompt)
}
cliSessionID := deriveSessionUUID(sessionKey)
disableTools := extractBoolOpt(req.Options, OptDisableTools)
bc := bridgeContextFromOpts(req.Options)
mcpPath := p.resolveMCPConfigPath(ctx, sessionKey, bc)
args := p.buildArgs(model, workDir, mcpPath, cliSessionID, "json", len(images) > 0, disableTools)
var stdin *bytes.Reader
if len(images) > 0 {
stdin = buildStreamJSONInput(userMsg, images)
} else {
args = append(args, "--", userMsg)
}
cmd := exec.CommandContext(ctx, p.cliPath, args...)
cmd.Dir = workDir
cmd.Env = filterCLIEnv(os.Environ())
if stdin != nil {
cmd.Stdin = stdin
}
var stderr bytes.Buffer
cmd.Stderr = &stderr
slog.Debug("claude-cli exec", "cmd", fmt.Sprintf("%s %s", p.cliPath, strings.Join(args, " ")), "workdir", workDir)
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("claude-cli: %w (stderr: %s)", err, stderr.String())
}
return parseJSONResponse(output)
}
// ChatStream runs the CLI with stream-json output, calling onChunk for each text delta.
func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ChatRequest, onChunk func(StreamChunk)) (*ChatResponse, error) {
systemPrompt, userMsg, images := extractFromMessages(req.Messages)
sessionKey := extractStringOpt(req.Options, OptSessionKey)
model := req.Model
if model == "" {
model = p.defaultModel
}
if err := validateCLIModel(model); err != nil {
return nil, err
}
slog.Debug("claude-cli: acquiring session lock", "session_key", sessionKey)
unlock := p.lockSession(sessionKey)
slog.Debug("claude-cli: session lock acquired", "session_key", sessionKey)
defer func() {
unlock()
slog.Debug("claude-cli: session lock released", "session_key", sessionKey)
}()
workDir := p.ensureWorkDir(sessionKey)
if systemPrompt != "" {
p.writeClaudeMD(workDir, systemPrompt)
}
cliSessionID := deriveSessionUUID(sessionKey)
disableTools := extractBoolOpt(req.Options, OptDisableTools)
bc := bridgeContextFromOpts(req.Options)
mcpPath := p.resolveMCPConfigPath(ctx, sessionKey, bc)
args := p.buildArgs(model, workDir, mcpPath, cliSessionID, "stream-json", len(images) > 0, disableTools)
var stdin *bytes.Reader
if len(images) > 0 {
stdin = buildStreamJSONInput(userMsg, images)
} else {
args = append(args, "--", userMsg)
}
cmd := exec.CommandContext(ctx, p.cliPath, args...)
cmd.Dir = workDir
cmd.Env = filterCLIEnv(os.Environ())
if stdin != nil {
cmd.Stdin = stdin
}
var stderrBuf bytes.Buffer
cmd.Stderr = &stderrBuf
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("claude-cli stdout pipe: %w", err)
}
fullCmd := fmt.Sprintf("%s %s", p.cliPath, strings.Join(args, " "))
slog.Debug("claude-cli stream exec", "cmd", fullCmd, "workdir", workDir)
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("claude-cli start: %w", err)
}
// Debug log file: only enabled when GOCLAW_DEBUG=1
var debugFile *os.File
if os.Getenv("GOCLAW_DEBUG") == "1" {
debugLogPath := filepath.Join(workDir, "cli-debug.log")
debugFile, _ = os.OpenFile(debugLogPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
if debugFile != nil {
fmt.Fprintf(debugFile, "=== CMD: %s\n=== WORKDIR: %s\n=== TIME: %s\n\n", fullCmd, workDir, time.Now().Format(time.RFC3339))
defer debugFile.Close()
}
}
// Parse stream-json line-by-line
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 0, 256*1024), 10*1024*1024) // 256KB initial, 10MB max
var finalResp ChatResponse
var contentBuf strings.Builder
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
// Write raw line to debug log
if debugFile != nil {
fmt.Fprintf(debugFile, "%s\n", line)
}
var ev cliStreamEvent
if err := json.Unmarshal(line, &ev); err != nil {
slog.Debug("claude-cli: skip malformed stream line", "error", err)
continue
}
switch ev.Type {
case "assistant":
if ev.Message == nil {
continue
}
text, thinking := extractStreamContent(ev.Message)
if text != "" {
contentBuf.WriteString(text)
onChunk(StreamChunk{Content: text})
}
if thinking != "" {
onChunk(StreamChunk{Thinking: thinking})
}
case "result":
if ev.Result != "" {
finalResp.Content = ev.Result
} else {
finalResp.Content = contentBuf.String()
}
finalResp.FinishReason = "stop"
if ev.Subtype == "error" {
finalResp.FinishReason = "error"
}
if ev.Usage != nil {
finalResp.Usage = &Usage{
PromptTokens: ev.Usage.InputTokens,
CompletionTokens: ev.Usage.OutputTokens,
TotalTokens: ev.Usage.InputTokens + ev.Usage.OutputTokens,
}
}
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("claude-cli: stream read error: %w", err)
}
if err := cmd.Wait(); err != nil {
if debugFile != nil {
fmt.Fprintf(debugFile, "\n=== STDERR:\n%s\n=== EXIT ERROR: %v\n", stderrBuf.String(), err)
}
// If we got partial content, return it with the error
if finalResp.Content != "" {
return &finalResp, nil
}
return nil, fmt.Errorf("claude-cli: %w (stderr: %s)", err, stderrBuf.String())
}
if debugFile != nil && stderrBuf.Len() > 0 {
fmt.Fprintf(debugFile, "\n=== STDERR:\n%s\n", stderrBuf.String())
}
// Fallback if no "result" event was received
if finalResp.Content == "" {
finalResp.Content = contentBuf.String()
finalResp.FinishReason = "stop"
}
onChunk(StreamChunk{Done: true})
return &finalResp, nil
}