mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 01:46:46 +08:00
* 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)
93 lines
3.5 KiB
Go
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
|
|
}
|
|
}
|