Files
multica/server/pkg/agent/codebuddy.go
Bohan Jiang a23b936b7b fix(agent): raise the agent stream scanner cap to 32 MiB and share it (MUL-5722) (#6383)
* fix(agent): raise the agent stream scanner cap to 32 MiB and share it (MUL-5722)

Codex serializes an entire thread into the single `thread/resume` response
line, so a long thread crossed the per-backend 10 MiB bufio.Scanner cap and
failed the resume with "bufio.Scanner: token too long". Because that
classifies as agent_error.process_failure, the session pointer survives and
every later turn resumes the same oversized thread — the task is stuck for
good. This is GH#4520 recurring: #4563 bounded the hang and the orphan
process, but the cap follow-up it promised was never done.

Raise the bound to 32 MiB and route every line-delimited agent transport
through one newAgentStreamScanner constructor. The cap was copy-pasted into
17 sites and had already drifted (pi at 32 MiB, everything else at 10 MiB),
which is how #4563's fix reached only one backend.

The three tests that deliberately overflow the scanner now size their
payloads from the constant, so raising the cap again cannot silently
downgrade them into plain oversized-line passes.

This is the containment layer only. A thread can still outgrow any fixed
cap, and the recovery path is still missing: overflow is reported as a
transport error, which blocks the thread/start fallback, and codex never
sets ResumeRejected so the daemon's fresh-session retry does not fire
either. Those remain open on MUL-5722.

Co-authored-by: multica-agent <github@multica.ai>

* fix(agent): route the QwenPaw ACP stream through the shared scanner (MUL-5722)

QwenPaw (#5986) landed on main after this branch was cut, carrying its own
bufio.Scanner with the old 10 MiB cap. Merging as-is would have left one
backend still on the cliff this PR exists to remove — the same way #4563's
fix originally reached only one backend.

Rebased onto main and routed it through newAgentStreamScanner. No stray
per-backend cap remains: the only bufio.NewScanner calls left in the package
are the model-listing and codex session-file parsers, which are deliberately
out of scope.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-05 13:09:25 +08:00

577 lines
18 KiB
Go

package agent
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"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",
// CodeBuddy's interactive tools have no UI to render in under the
// daemon's headless stream-json transport. AskUserQuestion and
// ExitPlanMode are both exempted from CodeBuddy's permission-mode
// finalization, so --permission-mode bypassPermissions does NOT
// auto-approve them — they always reach the permission bridge and
// stall the turn waiting for a confirmation nobody can give
// (GitHub #6012). EnterPlanMode is denied alongside them: leaving it
// enabled would let the model enter a plan mode it then has no tool
// to leave. Plan-shaped work still happens — the plan is written as
// ordinary assistant output instead of behind an approval gate.
//
// Pass one value per tool: --disallowedTools is variadic and
// CodeBuddy compares each entry against the tool name exactly
// (PermissionUtils.matchPermissionRules), so a comma-joined string
// would match nothing despite what the CLI's own help text claims.
"--disallowedTools", "AskUserQuestion", "EnterPlanMode", "ExitPlanMode",
}
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() { cleanupMcpConfigTemp(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 cleanupMcpConfigTemp(mcpConfigPath)
}
startTime := time.Now()
var lastAssistantText string
var finalResultText string
sawResult := false
resultIsError := false
var sessionID string
usage := make(map[string]TokenUsage)
eventCount := 0
invalidEventCount := 0
assistantEventCount := 0
toolUseCount := 0
// Close stdout when the context is cancelled so scanner.Scan() unblocks.
go func() {
<-runCtx.Done()
closeStdin()
_ = stdout.Close()
}()
scanner := newAgentStreamScanner(stdout)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
var msg codebuddySDKMessage
if err := json.Unmarshal([]byte(line), &msg); err != nil {
invalidEventCount++
continue
}
eventCount++
switch msg.Type {
case "assistant":
assistantEventCount++
assistantText, tools := b.handleAssistant(msg, msgCh, usage)
toolUseCount += tools
if tools == 0 {
lastAssistantText = assistantText
} else {
// A turn that invokes a tool is intermediate even when it also
// contains narration. Do not use it as an empty-result fallback.
lastAssistantText = ""
}
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":
sawResult = true
finalResultText = msg.ResultText
resultIsError = msg.IsError
sessionID = msg.SessionID
if resultUsage := codebuddyResultUsage(msg, opts.Model); len(resultUsage) > 0 {
usage = resultUsage
}
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)
}
}
scanErr := scanner.Err()
if scanErr != nil {
// Stop a malformed/oversized writer from blocking cmd.Wait after the
// scanner has stopped consuming stdout.
_ = stdout.Close()
}
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
finalStatus, finalOutput, finalError := finalizeStreamResult(
"codebuddy",
timeout,
runCtx.Err(),
writeErr,
exitErr,
sessionID,
streamTerminalState{
lastAssistantText: lastAssistantText,
finalResultText: finalResultText,
sawResult: sawResult,
resultIsError: resultIsError,
scanErr: scanErr,
},
"",
)
if finalError != "" {
finalError = withAgentStderr(finalError, "codebuddy", stderrBuf.Tail())
}
logStreamProtocolObservation(b.cfg.Logger, streamProtocolObservation{
provider: "codebuddy",
cliVersion: b.cfg.CLIVersion,
model: opts.Model,
exitCode: streamProcessExitCode(exitErr),
eventCount: eventCount,
invalidEventCount: invalidEventCount,
assistantEventCount: assistantEventCount,
toolUseCount: toolUseCount,
sawResult: sawResult,
resultIsError: resultIsError,
resultBytes: len(finalResultText),
lastAssistantBytes: len(lastAssistantText),
scannerError: scanErr != nil,
anthropicBaseURLConfigured: strings.TrimSpace(b.cfg.Env["ANTHROPIC_BASE_URL"]) != "",
})
b.cfg.Logger.Info("codebuddy finished", "pid", cmd.Process.Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String())
resumeRejected := resumeWasRejected(opts.ResumeSessionID, sessionID, finalStatus == "failed", finalError)
reportedSessionID := resolveSessionID(opts.ResumeSessionID, sessionID, finalStatus == "failed", finalError)
if resumeRejected {
b.cfg.Logger.Info("codebuddy resume was rejected; dropping session id and signalling fresh-session retry",
"requested_resume", opts.ResumeSessionID,
"emitted_session", sessionID,
)
}
resCh <- Result{
Status: finalStatus,
Output: finalOutput,
Error: finalError,
DurationMs: duration.Milliseconds(),
SessionID: reportedSessionID,
Usage: usage,
ResumeRejected: resumeRejected,
}
}()
return &Session{Messages: msgCh, Result: resCh}, nil
}
func (b *codebuddyBackend) handleAssistant(msg codebuddySDKMessage, ch chan<- Message, usage map[string]TokenUsage) (string, int) {
var content codebuddyMessageContent
if err := json.Unmarshal(msg.Message, &content); err != nil {
return "", 0
}
var assistantText strings.Builder
toolUseCount := 0
// 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 != "" {
assistantText.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":
toolUseCount++
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,
})
}
}
return assistantText.String(), toolUseCount
}
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{
// CodeBuddy's SdkPermissionClient reads `allowed` and treats a
// missing key as a denial; `behavior` is Claude Code's spelling,
// which the fork still honours on its other permission paths.
// Send both so an approval is never read as a silent reject.
"allowed": true,
"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
}