mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
* feat(agent): add codebuddyBackend struct and buildCodebuddyArgs Introduces the codebuddy agent backend skeleton with args builder that mirrors claudeBackend's protocol flags (stream-json, bypass permissions, blocked args filtering) for the codebuddy CLI fork. * feat(agent): implement codebuddyBackend.Execute with stream-json parsing * feat(agent): wire codebuddy into New() factory and launchHeaders * feat(agent): add codebuddy dynamic model discovery from --help * feat(agent): add codebuddy thinking/effort discovery and providerThinkingEnums * feat(daemon): add codebuddy CLI probe, env vars, and args support * fix(agent): use len(models)==0 for default model instead of loop index * fix(agent): increase codebuddy --help timeout to 35s for slow CLI startup * fix(agent): address codebuddy PR review feedback - Wire codebuddy into execenv: reuse claude's CLAUDE.md, .claude/skills, and ~/.claude/skills paths since CodeBuddy is a Claude Code fork - Replace hardcoded 20-min timeout with runContext for zero-timeout = no-deadline semantics matching all other backends - Restore runContext regression tests lost in rebase merge - Mirror claude.go execution model: concurrent stdin write to prevent pipe deadlock, sync.Once for stdin closure, keep stdin open for control_request auto-approval mid-run - Add control_request handling with auto-approve behavior - Add RequestID/Request fields to codebuddySDKMessage - Add codebuddy to metrics knownRuntimeProviders - Add codebuddy to provider-logo.tsx (reuses ClaudeLogo) - Consolidate --help discovery: shared codebuddyHelpOutput cache eliminates duplicate cold-start invocations --------- Co-authored-by: krislliu <krislliu@tencent.com>
522 lines
15 KiB
Go
522 lines
15 KiB
Go
package agent
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// codebuddyBackend implements Backend by spawning the CodeBuddy CLI
|
|
// (a Claude Code fork) with --output-format stream-json.
|
|
// It mirrors claude.go's execution model: concurrent stdin/stdout to
|
|
// avoid pipe deadlocks, open stdin for control_request auto-approval,
|
|
// and runContext for zero-timeout = no-deadline semantics.
|
|
type codebuddyBackend struct {
|
|
cfg Config
|
|
}
|
|
|
|
// codebuddyBlockedArgs are flags hardcoded by the daemon that must not be
|
|
// overridden by user-configured custom_args. Overriding these would break
|
|
// the daemon↔codebuddy communication protocol.
|
|
var codebuddyBlockedArgs = map[string]blockedArgMode{
|
|
"-p": blockedStandalone, // non-interactive mode
|
|
"--output-format": blockedWithValue, // stream-json protocol
|
|
"--input-format": blockedWithValue, // stream-json protocol
|
|
"--permission-mode": blockedWithValue, // bypassPermissions for autonomous operation
|
|
"--mcp-config": blockedWithValue, // set by daemon from agent.mcp_config
|
|
// `--effort` is owned by the per-agent thinking_level picker so a
|
|
// user-supplied custom_arg cannot silently outvote it.
|
|
"--effort": blockedWithValue,
|
|
}
|
|
|
|
func buildCodebuddyArgs(opts ExecOptions, logger *slog.Logger) []string {
|
|
args := []string{
|
|
"-p",
|
|
"--output-format", "stream-json",
|
|
"--input-format", "stream-json",
|
|
"--verbose",
|
|
"--strict-mcp-config",
|
|
"--permission-mode", "bypassPermissions",
|
|
"--disallowedTools", "AskUserQuestion",
|
|
}
|
|
if opts.Model != "" {
|
|
args = append(args, "--model", opts.Model)
|
|
}
|
|
if opts.ThinkingLevel != "" {
|
|
args = append(args, "--effort", opts.ThinkingLevel)
|
|
}
|
|
if opts.MaxTurns > 0 {
|
|
args = append(args, "--max-turns", fmt.Sprintf("%d", opts.MaxTurns))
|
|
}
|
|
if opts.SystemPrompt != "" {
|
|
args = append(args, "--append-system-prompt", opts.SystemPrompt)
|
|
}
|
|
if opts.ResumeSessionID != "" {
|
|
args = append(args, "--resume", opts.ResumeSessionID)
|
|
}
|
|
args = append(args, filterCustomArgs(opts.ExtraArgs, codebuddyBlockedArgs, logger)...)
|
|
args = append(args, filterCustomArgs(opts.CustomArgs, codebuddyBlockedArgs, logger)...)
|
|
return args
|
|
}
|
|
|
|
func (b *codebuddyBackend) Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error) {
|
|
execPath := b.cfg.ExecutablePath
|
|
if execPath == "" {
|
|
execPath = "codebuddy"
|
|
}
|
|
if _, err := exec.LookPath(execPath); err != nil {
|
|
return nil, fmt.Errorf("codebuddy executable not found at %q: %w", execPath, err)
|
|
}
|
|
|
|
timeout := opts.Timeout
|
|
runCtx, cancel := runContext(ctx, timeout)
|
|
|
|
args := buildCodebuddyArgs(opts, b.cfg.Logger)
|
|
|
|
// If the caller provided an MCP config, write it to a temp file and pass
|
|
// --mcp-config <path> so the agent uses a controlled set of MCP servers.
|
|
var mcpConfigPath string
|
|
var mcpFileCleanup func()
|
|
if len(opts.McpConfig) > 0 {
|
|
path, err := writeMcpConfigToTemp(opts.McpConfig)
|
|
if err != nil {
|
|
cancel()
|
|
return nil, err
|
|
}
|
|
mcpConfigPath = path
|
|
mcpFileCleanup = func() { os.Remove(mcpConfigPath) }
|
|
args = append(args, "--mcp-config", mcpConfigPath)
|
|
}
|
|
// Clean up the temp file if we return before the goroutine takes ownership.
|
|
defer func() {
|
|
if mcpFileCleanup != nil {
|
|
mcpFileCleanup()
|
|
}
|
|
}()
|
|
|
|
cmd := exec.CommandContext(runCtx, execPath, args...)
|
|
hideAgentWindow(cmd)
|
|
b.cfg.Logger.Info("agent command", "exec", execPath, "args", args)
|
|
cmd.WaitDelay = 10 * time.Second
|
|
if opts.Cwd != "" {
|
|
cmd.Dir = opts.Cwd
|
|
}
|
|
cmd.Env = buildEnv(b.cfg.Env)
|
|
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
cancel()
|
|
return nil, fmt.Errorf("codebuddy stdout pipe: %w", err)
|
|
}
|
|
stdin, err := cmd.StdinPipe()
|
|
if err != nil {
|
|
cancel()
|
|
return nil, fmt.Errorf("codebuddy stdin pipe: %w", err)
|
|
}
|
|
var closeStdinOnce sync.Once
|
|
closeStdin := func() { closeStdinOnce.Do(func() { _ = stdin.Close() }) }
|
|
|
|
stderrBuf := newStderrTail(newLogWriter(b.cfg.Logger, "[codebuddy:stderr] "), agentStderrTailBytes)
|
|
cmd.Stderr = stderrBuf
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
closeStdin()
|
|
cancel()
|
|
return nil, fmt.Errorf("start codebuddy: %w", err)
|
|
}
|
|
|
|
b.cfg.Logger.Info("codebuddy started", "pid", cmd.Process.Pid, "cwd", opts.Cwd, "model", opts.Model)
|
|
|
|
// cmd.Start() succeeded — transfer temp file ownership to the goroutine.
|
|
mcpFileCleanup = nil
|
|
|
|
msgCh := make(chan Message, 256)
|
|
resCh := make(chan Result, 1)
|
|
|
|
// Write the prompt in a dedicated goroutine to prevent deadlock.
|
|
// CodeBuddy (like Claude Code) emits a startup banner to stdout before
|
|
// reading stdin; a synchronous write would block once the pipe buffer
|
|
// fills. Keep stdin open after writing so control_request events can
|
|
// be answered mid-run.
|
|
writeDone := make(chan error, 1)
|
|
go func() {
|
|
err := writeCodebuddyInput(stdin, prompt)
|
|
if err != nil {
|
|
closeStdin()
|
|
}
|
|
writeDone <- err
|
|
}()
|
|
|
|
go func() {
|
|
defer cancel()
|
|
defer close(msgCh)
|
|
defer close(resCh)
|
|
if mcpConfigPath != "" {
|
|
defer os.Remove(mcpConfigPath)
|
|
}
|
|
|
|
startTime := time.Now()
|
|
var output strings.Builder
|
|
var sessionID string
|
|
finalStatus := "completed"
|
|
var finalError string
|
|
usage := make(map[string]TokenUsage)
|
|
|
|
// Close stdout when the context is cancelled so scanner.Scan() unblocks.
|
|
go func() {
|
|
<-runCtx.Done()
|
|
closeStdin()
|
|
_ = stdout.Close()
|
|
}()
|
|
|
|
scanner := bufio.NewScanner(stdout)
|
|
scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024)
|
|
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
var msg codebuddySDKMessage
|
|
if err := json.Unmarshal([]byte(line), &msg); err != nil {
|
|
continue
|
|
}
|
|
|
|
switch msg.Type {
|
|
case "assistant":
|
|
b.handleAssistant(msg, msgCh, &output, usage)
|
|
case "user":
|
|
b.handleUser(msg, msgCh)
|
|
case "system":
|
|
if msg.SessionID != "" {
|
|
sessionID = msg.SessionID
|
|
}
|
|
trySend(msgCh, Message{Type: MessageStatus, Status: "running", SessionID: sessionID})
|
|
case "result":
|
|
sessionID = msg.SessionID
|
|
if msg.ResultText != "" {
|
|
output.Reset()
|
|
output.WriteString(msg.ResultText)
|
|
}
|
|
if resultUsage := codebuddyResultUsage(msg, opts.Model); len(resultUsage) > 0 {
|
|
usage = resultUsage
|
|
}
|
|
if msg.IsError {
|
|
finalStatus = "failed"
|
|
finalError = msg.ResultText
|
|
}
|
|
closeStdin()
|
|
case "log":
|
|
if msg.Log != nil {
|
|
trySend(msgCh, Message{
|
|
Type: MessageLog,
|
|
Level: msg.Log.Level,
|
|
Content: msg.Log.Message,
|
|
})
|
|
}
|
|
case "control_request":
|
|
b.handleControlRequest(msg, stdin)
|
|
}
|
|
}
|
|
|
|
closeStdin()
|
|
|
|
// Wait for process exit.
|
|
exitErr := cmd.Wait()
|
|
duration := time.Since(startTime)
|
|
// writeDone is buffered (cap 1) and the writer always sends — by the
|
|
// time cmd has exited, the prompt write has either succeeded, hit a
|
|
// broken pipe, or been unblocked by the kill that ended cmd.
|
|
writeErr := <-writeDone
|
|
|
|
switch {
|
|
case runCtx.Err() == context.DeadlineExceeded:
|
|
finalStatus = "timeout"
|
|
finalError = fmt.Sprintf("codebuddy timed out after %s", timeout)
|
|
case runCtx.Err() == context.Canceled:
|
|
finalStatus = "aborted"
|
|
finalError = "execution cancelled"
|
|
case writeErr != nil && finalStatus == "completed" && sessionID == "":
|
|
// No result event landed and the prompt write failed — codebuddy
|
|
// died before reading the prompt. Surface the write error; the
|
|
// stderr tail attached below carries the real reason.
|
|
finalStatus = "failed"
|
|
finalError = fmt.Sprintf("write codebuddy input: %v", writeErr)
|
|
case exitErr != nil && finalStatus == "completed":
|
|
finalStatus = "failed"
|
|
finalError = fmt.Sprintf("codebuddy exited with error: %v", exitErr)
|
|
}
|
|
|
|
if finalError != "" {
|
|
finalError = withAgentStderr(finalError, "codebuddy", stderrBuf.Tail())
|
|
}
|
|
|
|
b.cfg.Logger.Info("codebuddy finished", "pid", cmd.Process.Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String())
|
|
|
|
reportedSessionID := resolveSessionID(opts.ResumeSessionID, sessionID, finalStatus == "failed")
|
|
if reportedSessionID != sessionID {
|
|
b.cfg.Logger.Info("codebuddy resume did not land; clearing fresh session id for daemon fallback",
|
|
"requested_resume", opts.ResumeSessionID,
|
|
"emitted_session", sessionID,
|
|
)
|
|
}
|
|
|
|
resCh <- Result{
|
|
Status: finalStatus,
|
|
Output: output.String(),
|
|
Error: finalError,
|
|
DurationMs: duration.Milliseconds(),
|
|
SessionID: reportedSessionID,
|
|
Usage: usage,
|
|
}
|
|
}()
|
|
|
|
return &Session{Messages: msgCh, Result: resCh}, nil
|
|
}
|
|
|
|
func (b *codebuddyBackend) handleAssistant(msg codebuddySDKMessage, ch chan<- Message, output *strings.Builder, usage map[string]TokenUsage) {
|
|
var content codebuddyMessageContent
|
|
if err := json.Unmarshal(msg.Message, &content); err != nil {
|
|
return
|
|
}
|
|
|
|
// Accumulate token usage per model.
|
|
if content.Usage != nil && content.Model != "" {
|
|
u := usage[content.Model]
|
|
u.InputTokens += content.Usage.InputTokens
|
|
u.OutputTokens += content.Usage.OutputTokens
|
|
u.CacheReadTokens += content.Usage.CacheReadInputTokens
|
|
u.CacheWriteTokens += content.Usage.CacheCreationInputTokens
|
|
usage[content.Model] = u
|
|
}
|
|
|
|
for _, block := range content.Content {
|
|
switch block.Type {
|
|
case "text":
|
|
if block.Text != "" {
|
|
output.WriteString(block.Text)
|
|
trySend(ch, Message{Type: MessageText, Content: block.Text})
|
|
}
|
|
case "thinking":
|
|
if block.Text != "" {
|
|
trySend(ch, Message{Type: MessageThinking, Content: block.Text})
|
|
}
|
|
case "tool_use":
|
|
var input map[string]any
|
|
if block.Input != nil {
|
|
_ = json.Unmarshal(block.Input, &input)
|
|
}
|
|
trySend(ch, Message{
|
|
Type: MessageToolUse,
|
|
Tool: block.Name,
|
|
CallID: block.ID,
|
|
Input: input,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func (b *codebuddyBackend) handleUser(msg codebuddySDKMessage, ch chan<- Message) {
|
|
var content codebuddyMessageContent
|
|
if err := json.Unmarshal(msg.Message, &content); err != nil {
|
|
return
|
|
}
|
|
|
|
for _, block := range content.Content {
|
|
if block.Type == "tool_result" {
|
|
resultStr := ""
|
|
if block.Content != nil {
|
|
resultStr = string(block.Content)
|
|
}
|
|
trySend(ch, Message{
|
|
Type: MessageToolResult,
|
|
CallID: block.ToolUseID,
|
|
Output: resultStr,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func (b *codebuddyBackend) handleControlRequest(msg codebuddySDKMessage, stdin interface{ Write([]byte) (int, error) }) {
|
|
// Auto-approve all tool uses in autonomous/daemon mode.
|
|
var req codebuddyControlRequestPayload
|
|
if err := json.Unmarshal(msg.Request, &req); err != nil {
|
|
return
|
|
}
|
|
|
|
var inputMap map[string]any
|
|
if req.Input != nil {
|
|
_ = json.Unmarshal(req.Input, &inputMap)
|
|
}
|
|
if inputMap == nil {
|
|
inputMap = map[string]any{}
|
|
}
|
|
|
|
response := map[string]any{
|
|
"type": "control_response",
|
|
"response": map[string]any{
|
|
"subtype": "success",
|
|
"request_id": msg.RequestID,
|
|
"response": map[string]any{
|
|
"behavior": "allow",
|
|
"updatedInput": inputMap,
|
|
},
|
|
},
|
|
}
|
|
|
|
data, err := json.Marshal(response)
|
|
if err != nil {
|
|
b.cfg.Logger.Warn("codebuddy: failed to marshal control response", "error", err)
|
|
return
|
|
}
|
|
data = append(data, '\n')
|
|
if _, err := stdin.Write(data); err != nil {
|
|
b.cfg.Logger.Warn("codebuddy: failed to write control response", "error", err)
|
|
}
|
|
}
|
|
|
|
func writeCodebuddyInput(w io.Writer, prompt string) error {
|
|
payload := map[string]any{
|
|
"type": "user",
|
|
"message": map[string]any{
|
|
"role": "user",
|
|
"content": []map[string]string{
|
|
{
|
|
"type": "text",
|
|
"text": prompt,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
data, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal codebuddy input: %w", err)
|
|
}
|
|
data = append(data, '\n')
|
|
if _, err := w.Write(data); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ── Codebuddy SDK JSON types ──
|
|
|
|
type codebuddySDKMessage struct {
|
|
Type string `json:"type"`
|
|
Message json.RawMessage `json:"message,omitempty"`
|
|
Subtype string `json:"subtype,omitempty"`
|
|
SessionID string `json:"session_id,omitempty"`
|
|
Model string `json:"model,omitempty"`
|
|
|
|
// result fields
|
|
ResultText string `json:"result,omitempty"`
|
|
IsError bool `json:"is_error,omitempty"`
|
|
DurationMs float64 `json:"duration_ms,omitempty"`
|
|
NumTurns int `json:"num_turns,omitempty"`
|
|
Usage *codebuddyUsage `json:"usage,omitempty"`
|
|
ModelUsage map[string]codebuddyResultModelUsage `json:"modelUsage,omitempty"`
|
|
|
|
// log fields
|
|
Log *codebuddyLogEntry `json:"log,omitempty"`
|
|
|
|
// control request fields
|
|
RequestID string `json:"request_id,omitempty"`
|
|
Request json.RawMessage `json:"request,omitempty"`
|
|
}
|
|
|
|
type codebuddyLogEntry struct {
|
|
Level string `json:"level"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
type codebuddyMessageContent struct {
|
|
Role string `json:"role"`
|
|
Model string `json:"model"`
|
|
Content []codebuddyContentBlock `json:"content"`
|
|
Usage *codebuddyUsage `json:"usage,omitempty"`
|
|
}
|
|
|
|
type codebuddyUsage struct {
|
|
InputTokens int64 `json:"input_tokens"`
|
|
OutputTokens int64 `json:"output_tokens"`
|
|
CacheReadInputTokens int64 `json:"cache_read_input_tokens"`
|
|
CacheCreationInputTokens int64 `json:"cache_creation_input_tokens"`
|
|
}
|
|
|
|
type codebuddyResultModelUsage struct {
|
|
InputTokens int64 `json:"inputTokens"`
|
|
OutputTokens int64 `json:"outputTokens"`
|
|
CacheReadInputTokens int64 `json:"cacheReadInputTokens"`
|
|
CacheCreationInputTokens int64 `json:"cacheCreationInputTokens"`
|
|
}
|
|
|
|
type codebuddyContentBlock struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text,omitempty"`
|
|
ID string `json:"id,omitempty"`
|
|
Name string `json:"name,omitempty"`
|
|
Input json.RawMessage `json:"input,omitempty"`
|
|
ToolUseID string `json:"tool_use_id,omitempty"`
|
|
Content json.RawMessage `json:"content,omitempty"`
|
|
}
|
|
|
|
type codebuddyControlRequestPayload struct {
|
|
Subtype string `json:"subtype"`
|
|
ToolName string `json:"tool_name,omitempty"`
|
|
Input json.RawMessage `json:"input,omitempty"`
|
|
}
|
|
|
|
func codebuddyResultUsage(msg codebuddySDKMessage, fallbackModel string) map[string]TokenUsage {
|
|
if len(msg.ModelUsage) > 0 {
|
|
usage := make(map[string]TokenUsage, len(msg.ModelUsage))
|
|
for model, u := range msg.ModelUsage {
|
|
if model == "" || !codebuddyUsageHasTokens(u.InputTokens, u.OutputTokens, u.CacheReadInputTokens, u.CacheCreationInputTokens) {
|
|
continue
|
|
}
|
|
usage[model] = TokenUsage{
|
|
InputTokens: u.InputTokens,
|
|
OutputTokens: u.OutputTokens,
|
|
CacheReadTokens: u.CacheReadInputTokens,
|
|
CacheWriteTokens: u.CacheCreationInputTokens,
|
|
}
|
|
}
|
|
if len(usage) > 0 {
|
|
return usage
|
|
}
|
|
}
|
|
|
|
model := msg.Model
|
|
if model == "" {
|
|
model = fallbackModel
|
|
}
|
|
if msg.Usage == nil || model == "" || !codebuddyUsageHasTokens(
|
|
msg.Usage.InputTokens,
|
|
msg.Usage.OutputTokens,
|
|
msg.Usage.CacheReadInputTokens,
|
|
msg.Usage.CacheCreationInputTokens,
|
|
) {
|
|
return nil
|
|
}
|
|
return map[string]TokenUsage{
|
|
model: {
|
|
InputTokens: msg.Usage.InputTokens,
|
|
OutputTokens: msg.Usage.OutputTokens,
|
|
CacheReadTokens: msg.Usage.CacheReadInputTokens,
|
|
CacheWriteTokens: msg.Usage.CacheCreationInputTokens,
|
|
},
|
|
}
|
|
}
|
|
|
|
func codebuddyUsageHasTokens(input, output, cacheRead, cacheWrite int64) bool {
|
|
return input > 0 || output > 0 || cacheRead > 0 || cacheWrite > 0
|
|
}
|