Files
multica/server/pkg/agent/stream_scanner.go
小七 5063fc9079 MUL-7869: fix(agent): fail kimi runs whose wire log records a failed turn (#9057)
* fix(agent): fail kimi runs whose wire log records a failed turn

kimi's ACP adapter can answer session/prompt successfully while its own
turn failed: the session wire log carries a
{"type":"agent.turn.ended","outcome":"failed",...} record (e.g. a bare
"OAuthConnectionError: OAuth request to https://auth.kimi.com/api/oauth/
token failed" that matches none of the stderr sniffer's terminal
signatures). The run then kept status=completed and published the
intermediate narration as a successfully delivered result, so the daemon
sent no failure signal and retry/continuation policy never saw the
failure (#9054).

Fix: after the run, scan the session wire logs - the same source,
turn-boundary rules, and tolerant-line policy as the usage scan
(scanKimiSessionUsage) - for the main agent's failed agent.turn.ended
record, and promote completed->failed carrying kimi's sanitized
diagnostic. Only the structured outcome verdict counts, so conversation
text echoing failure words cannot flip a healthy run; a delegated
agent's failed turn stays that agent's own task path; timeout, abort,
and already-failed runs keep their own terminal semantics.

Complementary to #8573's zero-output guard: that incident shape had 26
tool calls and nonempty narration, which a zero-output guard
deliberately preserves.

Fixes #9054

Generated-by: GLM-5.3-Flash (ZCode)

* fix(kimi): sanitize the wire diagnostic and scan past oversized records

Review fixes for #9057 (CHANGES_REQUESTED, both repros offline with a
fake ACP; no live provider requests):

P1: kimiWireTurnFailure copied the record's errorMessage into the run's
diagnostic unsanitized. The failure path persists Result.Error, and the
shared redact.Text does not redact a quoted JSON credential shape
(request={"api_key":...}) — the sanitized diagnostic the PR described
was not actually provided. The diagnostic now goes through the same
sanitizeAgentDiagnostic pass as every child-process diagnostic before
it reaches Result.Error.

P2: the scan loop used newAgentStreamScanner, whose bufio.Scanner ends
its scan for good at a line beyond agentStreamMaxLineBytes, and the
loop never checked scanner.Err() — a valid but oversized context.append
record ahead of the terminal event hid the failed outcome and kept the
run completed. kimiWireTurnFailure now reads through
readAgentStreamLine, which consumes and discards an over-bound record
whole and keeps the later records readable, and ends the walk
explicitly on any other read failure (keeping what was read, the same
best-effort policy as a truncated tail).

Regressions, all red before the fix and green after:
- TestScanKimiMainTurnFailureSanitizesWireDiagnostic (P1)
- TestScanKimiMainTurnFailureReadsPastOversizedRecord (P2, scan level)
- TestKimiBackendWireTurnFailurePastOversizedRecord (P2, backend e2e
  with the fake ACP: red as got "completed" (error=""), the review's
  exact repro shape)
- TestReadAgentStreamLineSkipsOversizedAndContinues /
  TestReadAgentStreamLineReturnsUnterminatedTail (helper contract; the
  first caught a boundary miss where the cap was crossed only on the
  final buffer chunk)

Generated-by: GLM-5.3-Flash (ZCode)
2026-10-08 10:13:54 -07:00

93 lines
3.5 KiB
Go

package agent
import (
"bufio"
"errors"
"io"
)
// agentStreamMaxLineBytes bounds a single line read from an agent CLI's
// event stream. Agent transports are line-delimited JSON, and one line can
// carry a whole conversation: Codex app-server serializes an entire thread
// into the single `thread/resume` response, and providers that re-send a
// growing message partial on every delta (Pi) or embed a large tool result
// in one event grow the same way. Crossing this bound makes scanner.Scan()
// return false with bufio.ErrTooLong, which the backends can only report as
// a transport failure — the session itself is fine, we just cannot read it.
//
// 32 MiB was picked as the follow-up to GH#4520, where the previous 10 MiB
// bound broke `thread/resume` for long Codex threads (MUL-5722). It is
// headroom, not a guarantee: a thread can still outgrow any fixed cap, so
// the recovery path matters more than the number.
const agentStreamMaxLineBytes = 32 * 1024 * 1024
// agentStreamInitialBufferBytes is the scanner's starting allocation. Lines
// above it still grow up to agentStreamMaxLineBytes; this only decides how
// much is reserved before the first grow, so ordinary events never realloc.
const agentStreamInitialBufferBytes = 1024 * 1024
// newAgentStreamScanner returns a bufio.Scanner sized for agent event
// streams. Every backend reading a line-delimited agent transport must go
// through this constructor: the bound used to be copy-pasted per backend and
// silently drifted, which is how GH#4520's fix reached only one of them.
func newAgentStreamScanner(r io.Reader) *bufio.Scanner {
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 0, agentStreamInitialBufferBytes), agentStreamMaxLineBytes)
return scanner
}
// readAgentStreamLine reads the next newline-delimited record from r and
// returns it without the newline (a single trailing '\r' is dropped, like
// bufio.Scanner's line splitting). It is the log-walking companion to
// newAgentStreamScanner for scans that read a session's persisted stream
// after the fact: where the Scanner ends its scan for good once a line
// passes agentStreamMaxLineBytes, readAgentStreamLine consumes and discards
// that record whole and returns bufio.ErrTooLong with r positioned after it,
// so later records stay readable — an oversized record must not hide the
// records behind it. A final unterminated line (a live log's partial-write
// tail) is returned whole, like Scanner does. The returned line is non-nil
// only when err is nil; an exhausted reader returns io.EOF. Any other read
// error is returned as-is.
func readAgentStreamLine(r *bufio.Reader) ([]byte, error) {
var line []byte
discard := false
for {
chunk, err := r.ReadSlice('\n')
if err == bufio.ErrBufferFull {
if !discard {
line = append(line, chunk...)
if discard = len(line) > agentStreamMaxLineBytes; discard {
line = nil // one record must not pin the bound in memory
}
}
continue
}
if err != nil {
if !errors.Is(err, io.EOF) {
return nil, err
}
if discard {
return nil, bufio.ErrTooLong
}
if tail := append(line, chunk...); len(tail) > 0 {
if len(tail) > agentStreamMaxLineBytes {
return nil, bufio.ErrTooLong
}
return tail, nil
}
return nil, io.EOF
}
if discard {
return nil, bufio.ErrTooLong
}
line = append(line, chunk[:len(chunk)-1]...)
if len(line) > agentStreamMaxLineBytes {
return nil, bufio.ErrTooLong
}
if n := len(line); n > 0 && line[n-1] == '\r' {
line = line[:n-1]
}
return line, nil
}
}