mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-06-10 08:11:23 +00:00
9249d9e358
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>
224 lines
6.0 KiB
Go
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
|
|
}
|