mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-11 02:46:45 +08:00
* 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]>
451 lines
14 KiB
Go
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
|
|
}
|