mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 20:15:42 +00:00
293 lines
8.9 KiB
Go
293 lines
8.9 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)
|
|
// Claude CLI >= v2.1.87 requires matching input/output formats.
|
|
// When images are present, buildArgs adds --input-format stream-json,
|
|
// so output format must also be stream-json.
|
|
outputFmt := "json"
|
|
if len(images) > 0 {
|
|
outputFmt = "stream-json"
|
|
}
|
|
effortLevel := extractStringOpt(req.Options, OptThinkingLevel)
|
|
allowedToolNames := extractStringSliceOpt(req.Options, OptAllowedToolNames)
|
|
args := p.buildArgs(model, workDir, mcpPath, cliSessionID, outputFmt, len(images) > 0, disableTools, effortLevel, allowedToolNames)
|
|
|
|
var stdin *bytes.Reader
|
|
if len(images) > 0 {
|
|
stdin = buildStreamJSONInput(userMsg, images)
|
|
} else {
|
|
args = append(args, "--", userMsg)
|
|
}
|
|
|
|
for {
|
|
cmd := exec.CommandContext(ctx, p.cliPath, args...)
|
|
cmd.Dir = workDir
|
|
cmd.Env = filterCLIEnv(os.Environ())
|
|
if effortLevel != "" && effortLevel != "off" {
|
|
// Explicit --effort flag takes precedence; drop env to avoid ambiguity.
|
|
cmd.Env = removeEnvKey(cmd.Env, "CLAUDE_CODE_EFFORT_LEVEL")
|
|
}
|
|
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 {
|
|
if tool, retry := p.noteInvalidDisallowedTool(stderr.String()); retry {
|
|
slog.Warn("claude-cli: retrying without unsupported --disallowedTools rule", "tool", tool)
|
|
args = p.buildArgs(model, workDir, mcpPath, cliSessionID, outputFmt, len(images) > 0, disableTools, effortLevel, allowedToolNames)
|
|
if len(images) == 0 {
|
|
args = append(args, "--", userMsg)
|
|
}
|
|
if len(images) > 0 {
|
|
stdin = buildStreamJSONInput(userMsg, images)
|
|
}
|
|
continue
|
|
}
|
|
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)
|
|
effortLevel := extractStringOpt(req.Options, OptThinkingLevel)
|
|
allowedToolNames := extractStringSliceOpt(req.Options, OptAllowedToolNames)
|
|
args := p.buildArgs(model, workDir, mcpPath, cliSessionID, "stream-json", len(images) > 0, disableTools, effortLevel, allowedToolNames)
|
|
|
|
var stdin *bytes.Reader
|
|
if len(images) > 0 {
|
|
stdin = buildStreamJSONInput(userMsg, images)
|
|
} else {
|
|
args = append(args, "--", userMsg)
|
|
}
|
|
|
|
for {
|
|
cmd := exec.CommandContext(ctx, p.cliPath, args...)
|
|
cmd.WaitDelay = 5 * time.Second // force-close pipes if process lingers after kill
|
|
cmd.Dir = workDir
|
|
cmd.Env = filterCLIEnv(os.Environ())
|
|
if effortLevel != "" && effortLevel != "off" {
|
|
cmd.Env = removeEnvKey(cmd.Env, "CLAUDE_CODE_EFFORT_LEVEL")
|
|
}
|
|
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, StdioScanBufInit), StdioScanBufMax)
|
|
|
|
var finalResp ChatResponse
|
|
var contentBuf strings.Builder
|
|
var streamErrMsg string // error message from stream-json result event
|
|
|
|
for scanner.Scan() {
|
|
if ctx.Err() != nil {
|
|
break // context cancelled (abort) → exit immediately
|
|
}
|
|
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" || ev.IsError {
|
|
finalResp.FinishReason = "error"
|
|
// Prefer ev.Error (result may be empty for usage/rate limit errors).
|
|
if ev.Error != "" {
|
|
streamErrMsg = ev.Error
|
|
} else if ev.Result != "" {
|
|
streamErrMsg = ev.Result
|
|
}
|
|
}
|
|
if ev.Usage != nil {
|
|
finalResp.Usage = &Usage{
|
|
PromptTokens: ev.Usage.InputTokens,
|
|
CompletionTokens: ev.Usage.OutputTokens,
|
|
TotalTokens: ev.Usage.InputTokens + ev.Usage.OutputTokens,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Context cancelled (abort): best-effort reap (bounded by WaitDelay), then return.
|
|
if ctx.Err() != nil {
|
|
_ = cmd.Wait()
|
|
return nil, ctx.Err()
|
|
}
|
|
|
|
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
|
|
}
|
|
stderrStr := strings.TrimSpace(stderrBuf.String())
|
|
if tool, retry := p.noteInvalidDisallowedTool(stderrStr); retry {
|
|
slog.Warn("claude-cli: retrying without unsupported --disallowedTools rule", "tool", tool)
|
|
args = p.buildArgs(model, workDir, mcpPath, cliSessionID, "stream-json", len(images) > 0, disableTools, effortLevel, allowedToolNames)
|
|
if len(images) == 0 {
|
|
args = append(args, "--", userMsg)
|
|
} else {
|
|
stdin = buildStreamJSONInput(userMsg, images)
|
|
}
|
|
continue
|
|
}
|
|
if stderrStr == "" && finalResp.FinishReason == "error" {
|
|
// claude-cli communicates API errors via stream-json stdout, not stderr.
|
|
if streamErrMsg != "" {
|
|
stderrStr = "stream: " + streamErrMsg
|
|
} else {
|
|
stderrStr = "stream error (no message)"
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("claude-cli: %w (stderr: %s)", err, stderrStr)
|
|
}
|
|
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
|
|
}
|
|
}
|