Files
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

199 lines
4.6 KiB
Go

package agentruntime
import (
"bytes"
"context"
"errors"
"fmt"
"log/slog"
"strings"
sessionreplay "github.com/tutti-os/tutti/packages/agent/session-replay"
)
func (c *acpClient) readLoop() {
var pending []byte
var stderrTail []byte
var stdoutTail []byte
for {
frame, err := c.conn.Recv()
if err != nil {
c.finish(err)
return
}
if len(frame.Stderr) > 0 {
stderrTail = append(stderrTail, frame.Stderr...)
if len(stderrTail) > acpClientOutputTailLimit {
stderrTail = stderrTail[len(stderrTail)-acpClientOutputTailLimit:]
}
c.setStderrTail(stderrTail)
c.mu.Lock()
stderrSink := c.stderrSink
c.mu.Unlock()
if stderrSink != nil {
stderrSink(frame.Stderr)
}
if c.stderrMessageMapper != nil {
if message, ok := c.stderrMessageMapper(frame.Stderr); ok {
unit := providerInputUnit(
frame,
1,
sessionreplay.ProviderInputUnitMappedStderr,
)
c.dispatchMessageAt(message, &unit)
if err := c.completeProviderInputUnit(
frame,
1,
sessionreplay.ProviderInputUnitMappedStderr,
); err != nil {
c.finish(err)
return
}
}
}
// Provider stderr is already retained in the startup trace and the
// bounded diagnostics tail. It is normal for providers to emit many
// small stderr frames, so keep this duplicate diagnostic off the
// default warning log path.
slog.Debug("agent session ACP stderr",
"event", "agent_session.acp.stderr",
"message", truncateACPLogValue(string(frame.Stderr), 1200),
)
continue
}
if frame.ExitCode != nil {
unit := providerInputUnit(
frame,
1,
sessionreplay.ProviderInputUnitProcessExit,
)
if err := c.completeProviderInputUnit(
frame,
1,
sessionreplay.ProviderInputUnitProcessExit,
); err != nil {
c.finish(err)
return
}
c.setExitCode(*frame.ExitCode)
message := strings.TrimSpace(frame.Message)
if stderr := strings.TrimSpace(string(stderrTail)); stderr != "" {
message = firstNonEmpty(message, "process exited") + ": " + stderr
}
c.finish(providerInputUnitError{
err: fmt.Errorf(
"acp process exited with code %d: %s",
*frame.ExitCode,
message,
),
unit: unit,
})
return
}
if len(frame.Stdout) == 0 {
continue
}
stdoutTail = append(stdoutTail, frame.Stdout...)
if len(stdoutTail) > acpClientOutputTailLimit {
stdoutTail = stdoutTail[len(stdoutTail)-acpClientOutputTailLimit:]
}
c.setStdoutTail(stdoutTail)
pending = append(pending, frame.Stdout...)
// Synthetic optional-probe / startup-metadata responses are not cassette
// units. Completing them would report ChunkSeq=0 and trip the provider
// input barrier's "position moved backward" guard after real frames.
if frame.Synthetic {
for {
line, rest, ok := bytes.Cut(pending, []byte("\n"))
if !ok {
break
}
pending = rest
c.dispatchLine(line)
}
continue
}
var unitIndex uint64
for {
line, rest, ok := bytes.Cut(pending, []byte("\n"))
if !ok {
break
}
pending = rest
nextUnitIndex := unitIndex + 1
if !c.dispatchLineAt(line, frame, nextUnitIndex) {
continue
}
unitIndex = nextUnitIndex
for {
err := c.completeProviderInputUnit(
frame,
unitIndex,
sessionreplay.ProviderInputUnitProtocolMessage,
)
if errors.Is(err, errReplaySyntheticPending) {
if drainErr := c.drainSyntheticOptionalProbeResponses(); drainErr != nil {
c.finish(drainErr)
return
}
continue
}
if err != nil {
c.finish(err)
return
}
break
}
}
}
}
func (c *acpClient) drainSyntheticOptionalProbeResponses() error {
replayConn, ok := c.conn.(interface{ HasPendingSyntheticStdout() bool })
if !ok {
return nil
}
for replayConn.HasPendingSyntheticStdout() {
frame, err := c.conn.Recv()
if err != nil {
return err
}
if len(frame.Stdout) == 0 {
continue
}
pending := append([]byte(nil), frame.Stdout...)
for {
line, rest, cut := bytes.Cut(pending, []byte("\n"))
if !cut {
break
}
pending = rest
c.dispatchLine(line)
}
}
return nil
}
func (c *acpClient) completeProviderInputUnit(
frame ProcessFrame,
unitIndex uint64,
kind sessionreplay.ProviderInputUnitKind,
) error {
completion, ok := c.conn.(ProviderInputUnitCompletion)
if !ok {
return nil
}
return completion.CompleteProviderInputUnit(
context.Background(),
ProviderInputUnit{
RecordingID: frame.RecordingID,
Position: sessionreplay.ProviderUnitPosition{
ConnectionID: frame.ConnectionID,
ChunkSeq: frame.ChunkSeq,
UnitIndex: unitIndex,
},
Kind: kind,
},
)
}