Files
multica/server/pkg/agent/traecli.go
T
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 <[email protected]>

* 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 <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-05 13:09:25 +08:00

451 lines
14 KiB
Go

package agent
import (
"context"
"fmt"
"io"
"os/exec"
"strings"
"sync"
"sync/atomic"
"time"
)
// traecliBlockedArgs are flags hardcoded by the daemon that must not be
// overridden by user-configured custom_args. `acp` and `serve` are the
// protocol subcommand/action; `-y`/`--yolo` is daemon-owned so headless ACP
// always runs in bypass-permissions mode (the official traecli gates non-read
// tools behind a permission prompt otherwise — see
// https://docs.trae.cn/cli_permission-mode). `--print`/`-p` and
// `--output-format` would switch the binary out of ACP into print mode and
// break the daemon↔traecli transport, and `--permission-mode` is owned by the
// daemon via --yolo.
var traecliBlockedArgs = map[string]blockedArgMode{
"acp": blockedStandalone,
"serve": blockedStandalone,
"-y": blockedStandalone,
"--yolo": blockedStandalone,
"-p": blockedStandalone,
"--print": blockedStandalone,
"--output-format": blockedWithValue,
"--permission-mode": blockedWithValue,
}
// traecliBackend implements Backend by spawning `traecli acp serve --yolo` and
// communicating via the standard ACP (Agent Client Protocol) JSON-RPC 2.0
// transport over stdin/stdout.
//
// This targets ByteDance's official TRAE CLI (the `traecli` binary documented
// at https://docs.trae.cn/cli — NOT the open-source bytedance/trae-agent
// `trae-cli`, which has no ACP transport). traecli is ACP-native: it advertises
// `loadSession: true` and `mcpCapabilities: {http, sse}` from `initialize`,
// returns its model catalog from `session/new`, and supports `session/load`
// (resume), `session/set_model`, and `session/prompt`. That lets the existing
// Hermes/Kimi/Kiro/Qoder ACP client (hermesClient) drive it with only
// provider-specific launch args and tool-name normalization.
//
// The `initialize` capabilities above were captured from the real traecli
// v0.120.42 binary; the streaming gate mirrors the other ACP backends so
// history replay flushed during session setup does not corrupt the streamed
// output.
type traecliBackend struct {
cfg Config
}
var traecliReaderDrainGrace = 2 * time.Second
// traecliMessageStream serializes sends and the final close so a late stdout
// reader (traecli may keep the process and its pipes open briefly after
// session/prompt returns) cannot send on a closed channel. Mirrors qoder.
type traecliMessageStream struct {
ch chan Message
mu sync.Mutex
closed bool
}
func newTraecliMessageStream(size int) *traecliMessageStream {
return &traecliMessageStream{ch: make(chan Message, size)}
}
func (s *traecliMessageStream) send(msg Message) {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return
}
trySend(s.ch, msg)
}
func (s *traecliMessageStream) close() {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return
}
s.closed = true
close(s.ch)
}
func (b *traecliBackend) Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error) {
execPath := b.cfg.ExecutablePath
if execPath == "" {
execPath = "traecli"
}
if _, err := exec.LookPath(execPath); err != nil {
return nil, fmt.Errorf("traecli executable not found at %q: %w", execPath, err)
}
// Translate the agent's mcp_config (Claude-style object of objects) into
// the array shape ACP session/new and session/load expect. Fail closed on
// malformed JSON so the launch surfaces the real error instead of silently
// dropping every MCP server.
mcpServers, err := buildACPMcpServers(opts.McpConfig, b.cfg.Logger)
if err != nil {
return nil, fmt.Errorf("traecli: invalid mcp_config: %w", err)
}
timeout := opts.Timeout
runCtx, cancel := runContext(ctx, timeout)
traecliArgs := append(
[]string{"acp", "serve", "--yolo"},
filterCustomArgs(opts.CustomArgs, traecliBlockedArgs, b.cfg.Logger)...,
)
cmd := exec.CommandContext(runCtx, execPath, traecliArgs...)
hideAgentWindow(cmd)
b.cfg.Logger.Info("agent command", "exec", execPath, "args", traecliArgs)
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("traecli stdout pipe: %w", err)
}
stdin, err := cmd.StdinPipe()
if err != nil {
cancel()
return nil, fmt.Errorf("traecli stdin pipe: %w", err)
}
// StderrPipe + an explicit copier give us a join point (`stderrDone`) that
// fires before the failure-promotion decision; see the matching comment in
// hermes.go for why the io.MultiWriter form races with stopReason=end_turn
// under load.
providerErr := newACPProviderErrorSniffer("traecli")
stderr, err := cmd.StderrPipe()
if err != nil {
cancel()
return nil, fmt.Errorf("traecli stderr pipe: %w", err)
}
if err := cmd.Start(); err != nil {
cancel()
return nil, fmt.Errorf("start traecli: %w", err)
}
stderrSink := io.MultiWriter(newLogWriter(b.cfg.Logger, "[traecli:stderr] "), providerErr)
stderrDone := make(chan struct{})
go func() {
defer close(stderrDone)
_, _ = io.Copy(stderrSink, stderr)
}()
b.cfg.Logger.Info("traecli acp started", "pid", cmd.Process.Pid, "cwd", opts.Cwd)
msgStream := newTraecliMessageStream(256)
resCh := make(chan Result, 1)
// Traecli streams interim narration and the final answer as the same
// agent_message_chunk type; the tracker keeps only the post-tool-call block
// for Result.Output while retaining the full text for error detection.
var deliverable acpDeliverableTracker
var streamingCurrentTurn atomic.Bool
promptDone := make(chan hermesPromptResult, 1)
activity := make(chan struct{}, 1)
c := &hermesClient{
cfg: b.cfg,
stdin: stdin,
pending: make(map[int]*pendingRPC),
pendingTools: make(map[string]*pendingToolCall),
acceptNotification: func(string) bool {
return streamingCurrentTurn.Load()
},
onActivity: func() {
select {
case activity <- struct{}{}:
default:
}
},
onMessage: func(msg Message) {
if !streamingCurrentTurn.Load() {
return
}
if msg.Type == MessageToolUse {
msg.Tool = kimiToolNameFromTitle(msg.Tool)
}
deliverable.observe(msg)
msgStream.send(msg)
},
onPromptDone: func(result hermesPromptResult) {
if !streamingCurrentTurn.Load() {
return
}
select {
case promptDone <- result:
default:
}
},
}
readerDone := make(chan struct{})
go func() {
defer close(readerDone)
scanner := newAgentStreamScanner(stdout)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
c.handleLine(line)
}
c.closeAllPending(fmt.Errorf("traecli process exited"))
}()
go func() {
defer cancel()
defer msgStream.close()
defer close(resCh)
defer func() {
stdin.Close()
_ = cmd.Wait()
}()
startTime := time.Now()
finalStatus := "completed"
var finalError string
var sessionID string
// Set when the ACP runtime refuses the session we asked to
// resume. Only that is curable by starting a fresh session, so
// handshake/network failures below must leave it false.
var resumeRejected bool
effectiveModel := strings.TrimSpace(opts.Model)
initResult, err := c.request(runCtx, "initialize", map[string]any{
"protocolVersion": 1,
"clientInfo": map[string]any{
"name": "multica-agent-sdk",
"version": "0.2.0",
},
"clientCapabilities": map[string]any{},
})
if err != nil {
finalStatus = "failed"
finalError = fmt.Sprintf("traecli initialize failed: %v", err)
resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()}
return
}
// Drop MCP entries whose remote transport the runtime didn't advertise
// (traecli advertises mcpCapabilities {http, sse}). See hermes.go for
// why sending an unsupported transport tanks the whole session/new.
mcpServers = filterACPMcpServersByCapability(mcpServers, extractACPMcpCapabilities(initResult), "traecli", b.cfg.Logger)
cwd := opts.Cwd
if cwd == "" {
cwd = "."
}
if opts.ResumeSessionID != "" {
// traecli advertises loadSession:true, so resume goes through the
// standard ACP session/load (same as Kiro).
result, err := c.request(runCtx, "session/load", map[string]any{
"cwd": cwd,
"sessionId": opts.ResumeSessionID,
"mcpServers": mcpServers,
})
if err != nil {
finalStatus = "failed"
finalError = fmt.Sprintf("traecli session/load failed: %v", err)
resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()}
return
}
var changed bool
sessionID, changed = resolveResumedSessionID(opts.ResumeSessionID, result)
if changed {
b.cfg.Logger.Warn("agent returned a different session id on resume — original was likely lost; continuing with the new id",
"backend", "traecli",
"requested", opts.ResumeSessionID,
"actual", sessionID,
)
}
if effectiveModel == "" {
effectiveModel = extractACPCurrentModelID(result)
}
} else {
result, err := c.request(runCtx, "session/new", map[string]any{
"cwd": cwd,
"mcpServers": mcpServers,
})
if err != nil {
finalStatus = "failed"
finalError = fmt.Sprintf("traecli session/new failed: %v", err)
resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()}
return
}
sessionID = extractACPSessionID(result)
if sessionID == "" {
finalStatus = "failed"
finalError = "traecli session/new returned no session ID"
resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()}
return
}
if effectiveModel == "" {
effectiveModel = extractACPCurrentModelID(result)
}
}
c.sessionID = sessionID
b.cfg.Logger.Info("traecli session created", "session_id", sessionID)
if opts.Model != "" {
if _, err := c.request(runCtx, "session/set_model", map[string]any{
"sessionId": sessionID,
"modelId": opts.Model,
}); err != nil {
b.cfg.Logger.Warn("traecli set_session_model failed", "error", err, "requested_model", opts.Model)
finalStatus = "failed"
finalError = fmt.Sprintf("traecli could not switch to model %q: %v", opts.Model, err)
if opts.ResumeSessionID != "" && isACPSessionNotFound(err) {
b.cfg.Logger.Warn("resumed session not found at set_model time; clearing session id so the daemon retries fresh",
"backend", "traecli",
"session_id", sessionID,
)
sessionID = ""
resumeRejected = true
}
resCh <- Result{
Status: finalStatus,
Error: finalError,
DurationMs: time.Since(startTime).Milliseconds(),
SessionID: sessionID,
ResumeRejected: resumeRejected,
}
return
}
b.cfg.Logger.Info("traecli session model set", "model", opts.Model)
}
userText := prompt
if opts.SystemPrompt != "" {
userText = opts.SystemPrompt + "\n\n---\n\n" + prompt
}
streamingCurrentTurn.Store(true)
_, err = c.request(runCtx, "session/prompt", map[string]any{
"sessionId": sessionID,
"prompt": []map[string]any{
{"type": "text", "text": userText},
},
})
if err != nil {
if runCtx.Err() == context.DeadlineExceeded {
finalStatus = "timeout"
finalError = fmt.Sprintf("traecli timed out after %s", timeout)
} else if runCtx.Err() == context.Canceled {
finalStatus = "aborted"
finalError = "execution cancelled"
} else {
finalStatus = "failed"
finalError = fmt.Sprintf("traecli session/prompt failed: %v", err)
if opts.ResumeSessionID != "" && isACPSessionNotFound(err) {
b.cfg.Logger.Warn("resumed session not found at prompt time; clearing session id so the daemon retries fresh",
"backend", "traecli",
"session_id", sessionID,
)
sessionID = ""
resumeRejected = true
}
}
} else {
select {
case pr := <-promptDone:
if pr.stopReason == "cancelled" {
finalStatus = "aborted"
finalError = "traecli cancelled the prompt"
}
c.usageMu.Lock()
c.usage.InputTokens += pr.usage.InputTokens
c.usage.OutputTokens += pr.usage.OutputTokens
c.usage.CacheReadTokens += pr.usage.CacheReadTokens
c.usageMu.Unlock()
default:
}
waitForACPNotificationQuiescence(runCtx, activity, readerDone, acpNotificationQuietTime, traecliReaderDrainGrace)
}
duration := time.Since(startTime)
b.cfg.Logger.Info("traecli finished", "pid", cmd.Process.Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String())
stdin.Close()
cancel()
// traecli ACP may keep the process — and the stdout/stderr pipes — open
// briefly after session/prompt returns. The prompt response is already
// terminal, so bound the drain: wait for the stdout reader and the
// stderr copier, but no longer than the grace window (CommandContext
// cancellation tears the process down). Draining stderr is what makes
// the provider-error promotion below see a terminal marker.
drainCtx, drainCancel := context.WithTimeout(context.Background(), traecliReaderDrainGrace)
select {
case <-readerDone:
case <-drainCtx.Done():
}
select {
case <-stderrDone:
case <-drainCtx.Done():
}
drainCancel()
// Flip the gate before the defer closes msgStream; a late reader that
// already passed the gate is serialized by traecliMessageStream so the
// late send is dropped instead of panicking.
streamingCurrentTurn.Store(false)
finalOutput, providerErrorOutput := deliverable.result()
// Promote completed→failed when stderr or the agent text stream show a
// terminal upstream-LLM failure (HTTP 4xx / rate-limit / expired token).
// Mirrors hermes/kimi/kiro/qoder, and reads the full text stream so a
// give-up turn that lands before a tool call stays visible.
finalStatus, finalError = promoteACPResultOnProviderError(finalStatus, finalError, providerErrorOutput, providerErr)
c.usageMu.Lock()
u := c.usage
c.usageMu.Unlock()
var usageMap map[string]TokenUsage
if u.InputTokens > 0 || u.OutputTokens > 0 || u.CacheReadTokens > 0 || u.CacheWriteTokens > 0 {
model := effectiveModel
if model == "" {
model = "unknown"
}
usageMap = map[string]TokenUsage{model: u}
}
resCh <- Result{
Status: finalStatus,
Output: finalOutput,
Error: finalError,
DurationMs: duration.Milliseconds(),
SessionID: sessionID,
ResumeRejected: resumeRejected,
Usage: usageMap,
}
}()
return &Session{Messages: msgStream.ch, Result: resCh}, nil
}