Record content-free stream terminal observations
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
ci / vet-and-test (pull_request) Successful in 52s

Why: The r0.4 trial could prove that the parser missed [DONE] but could not distinguish clean EOF, malformed frames, read failure, or provider error without retaining raw SSE.

Evidence: RED receipts fast-slow-u3-r05-task1-terminal-red.json and task1-http-error-red.json; GREEN targeted receipt and full vet/test/race receipt fast-slow-u3-r05-task1-agent-native-full.json.

Impact: Add bounded scalar diagnostics and successful HTTP status to terminal events while preserving the existing incomplete-tool rejection, returned Usage, and retry behavior.

Refs: U3 r0.5 approved design and plan; Agent-Native PR #46
This commit is contained in:
weichen
2026-10-14 07:50:39 +00:00
parent a5e3c7c817
commit 6a1dcddb48
5 changed files with 249 additions and 28 deletions
+12 -8
View File
@@ -100,10 +100,10 @@ func (c *Client) run(ctx context.Context, model string, messages []Message, tool
resp, err := hc.Do(req) resp, err := hc.Do(req)
if err != nil { if err != nil {
if ctx.Err() != nil { if ctx.Err() != nil {
emitAbort(out, "", ctx.Err()) emitAbort(out, "", ctx.Err(), &TerminalObservation{ReadOutcome: "cancelled"})
return return
} }
emitError(out, "", true, err.Error()) emitError(out, "", true, err.Error(), &TerminalObservation{ReadOutcome: "transport_error"})
return return
} }
defer resp.Body.Close() defer resp.Body.Close()
@@ -111,12 +111,13 @@ func (c *Client) run(ctx context.Context, model string, messages []Message, tool
slurp, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) slurp, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
msg := fmt.Sprintf("provider status %d: %s", resp.StatusCode, strings.TrimSpace(string(slurp))) msg := fmt.Sprintf("provider status %d: %s", resp.StatusCode, strings.TrimSpace(string(slurp)))
ev := Event{ ev := Event{
Kind: KindError, Kind: KindError,
HTTPStatus: resp.StatusCode, HTTPStatus: resp.StatusCode,
Retryable: StatusRetryable(resp.StatusCode) && !ContextOverflow(msg), TerminalObservation: &TerminalObservation{ReadOutcome: "provider_error"},
ErrorMessage: msg, Retryable: StatusRetryable(resp.StatusCode) && !ContextOverflow(msg),
Err: &StreamError{Message: msg, Retryable: StatusRetryable(resp.StatusCode) && !ContextOverflow(msg)}, ErrorMessage: msg,
Message: Message{Role: RoleAssistant, StopReason: StopError}, Err: &StreamError{Message: msg, Retryable: StatusRetryable(resp.StatusCode) && !ContextOverflow(msg)},
Message: Message{Role: RoleAssistant, StopReason: StopError},
} }
if d, ok := ParseRetryAfter(resp.Header.Get("Retry-After"), time.Now()); ok { if d, ok := ParseRetryAfter(resp.Header.Get("Retry-After"), time.Now()); ok {
ev.HasRetryAfter = true ev.HasRetryAfter = true
@@ -126,6 +127,9 @@ func (c *Client) run(ctx context.Context, model string, messages []Message, tool
return return
} }
for ev := range ParseSSE(ctx, resp.Body) { for ev := range ParseSSE(ctx, resp.Body) {
if ev.Kind == KindMessageEnd || ev.Kind == KindError {
ev.HTTPStatus = resp.StatusCode
}
out <- ev out <- ev
} }
} }
+65
View File
@@ -0,0 +1,65 @@
package llm
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
)
type terminalRoundTripFunc func(*http.Request) (*http.Response, error)
func (f terminalRoundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func TestClientTerminalObservationHTTPStatus(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/chat/completions" {
t.Errorf("path = %q", r.URL.Path)
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":2,\"completion_tokens\":1,\"total_tokens\":3}}\n\ndata: [DONE]\n\n"))
}))
defer server.Close()
client := &Client{BaseURL: server.URL, APIKey: "fixture", HTTPClient: server.Client()}
var end Event
for ev := range client.Stream(context.Background(), "fixture", []Message{{Role: RoleUser, Content: "hello"}}, nil, StreamOptions{}) {
if ev.Kind == KindMessageEnd {
end = ev
}
}
if end.HTTPStatus != http.StatusOK || end.TerminalObservation == nil ||
end.TerminalObservation.ReadOutcome != "done_marker" || !end.StreamComplete || end.Usage.TotalTokens != 3 {
t.Fatalf("successful HTTP terminal lost transport evidence: %#v", end)
}
}
func TestClientTerminalObservationProviderAndTransportFailure(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "PRIVATE_PROVIDER_BODY_R05", http.StatusTooManyRequests)
}))
defer server.Close()
tests := []struct {
name string
client *Client
status int
outcome string
}{
{"provider-http", &Client{BaseURL: server.URL, APIKey: "fixture", HTTPClient: server.Client()}, http.StatusTooManyRequests, "provider_error"},
{"transport", &Client{BaseURL: "http://test.invalid", APIKey: "fixture", HTTPClient: &http.Client{Transport: terminalRoundTripFunc(func(*http.Request) (*http.Response, error) {
return nil, errors.New("PRIVATE_TRANSPORT_ERROR_R05")
})}}, 0, "transport_error"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var end Event
for ev := range tc.client.Stream(context.Background(), "fixture", []Message{{Role: RoleUser, Content: "hello"}}, nil, StreamOptions{}) {
end = ev
}
if end.Kind != KindError || end.HTTPStatus != tc.status || end.TerminalObservation == nil ||
end.TerminalObservation.ReadOutcome != tc.outcome {
t.Fatalf("provider/transport failure was not classified: %#v", end)
}
})
}
}
+73 -16
View File
@@ -39,6 +39,11 @@ type usageWire struct {
} `json:"prompt_tokens_details"` } `json:"prompt_tokens_details"`
} }
const (
maxObservedToolFragments = 10000
maxObservedToolArgumentBytes = 4*1024*1024 + 1
)
// ParseSSE reads an OpenAI-compatible server-sent event body and emits // ParseSSE reads an OpenAI-compatible server-sent event body and emits
// deltas plus one terminal message_end or error event. // deltas plus one terminal message_end or error event.
func ParseSSE(ctx context.Context, r io.Reader) <-chan Event { func ParseSSE(ctx context.Context, r io.Reader) <-chan Event {
@@ -59,15 +64,36 @@ func emitParsed(ctx context.Context, r io.Reader, out chan<- Event) {
finished := "" finished := ""
sawDone := false sawDone := false
sawUsage := false sawUsage := false
readOutcome := "clean_eof"
lastFrameJSONComplete := false
toolFragments := 0
toolArgumentBytes := 0
observation := func() *TerminalObservation {
argumentsValid := len(order) > 0
for _, idx := range order {
var args map[string]json.RawMessage
if err := json.Unmarshal([]byte(calls[idx].Arguments), &args); err != nil || args == nil {
argumentsValid = false
break
}
}
return &TerminalObservation{
ReadOutcome: readOutcome, LastFrameJSONComplete: lastFrameJSONComplete,
UsagePresent: sawUsage, ToolFragments: toolFragments,
ToolArgumentBytes: toolArgumentBytes, ToolArgumentsValid: argumentsValid,
}
}
sendTerminalFailure := func(why string) { sendTerminalFailure := func(why string) {
message := Message{Role: RoleAssistant, Content: content.String(), StopReason: StopError} message := Message{Role: RoleAssistant, Content: content.String(), StopReason: StopError}
if reasoning.Len() > 0 { if reasoning.Len() > 0 {
message.Thinking = []ThinkingBlock{{Thinking: reasoning.String()}} message.Thinking = []ThinkingBlock{{Thinking: reasoning.String()}}
} }
failed := terminalStreamFailure(message, usage, finished, sawDone, why) failed := terminalStreamFailure(message, usage, finished, sawDone, why)
failed.TerminalObservation = observation()
select { select {
case <-ctx.Done(): case <-ctx.Done():
emitAbort(out, content.String(), ctx.Err()) readOutcome = "cancelled"
emitAbort(out, content.String(), ctx.Err(), observation())
case out <- failed: case out <- failed:
} }
} }
@@ -80,30 +106,36 @@ func emitParsed(ctx context.Context, r io.Reader, out chan<- Event) {
break break
} }
if ctx.Err() != nil { if ctx.Err() != nil {
emitAbort(out, content.String(), ctx.Err()) readOutcome = "cancelled"
emitAbort(out, content.String(), ctx.Err(), observation())
return return
} }
if data == "[DONE]" { if data == "[DONE]" {
sawDone = true sawDone = true
readOutcome = "done_marker"
break break
} }
var chunk sseChunk var chunk sseChunk
if err := json.Unmarshal([]byte(data), &chunk); err != nil { if err := json.Unmarshal([]byte(data), &chunk); err != nil {
readOutcome = "invalid_frame"
lastFrameJSONComplete = false
if sawUsage { if sawUsage {
sendTerminalFailure("malformed stream chunk after reported usage") sendTerminalFailure("malformed stream chunk after reported usage")
return return
} }
emitError(out, content.String(), false, "malformed stream chunk: "+err.Error()) emitError(out, content.String(), false, "malformed stream chunk: "+err.Error(), observation())
return return
} }
lastFrameJSONComplete = true
if chunk.Error != nil && chunk.Error.Message != "" { if chunk.Error != nil && chunk.Error.Message != "" {
readOutcome = "provider_error"
msg := chunk.Error.Message msg := chunk.Error.Message
if sawUsage { if sawUsage {
sendTerminalFailure("provider error after reported usage: " + msg) sendTerminalFailure("provider error after reported usage: " + msg)
return return
} }
retryable := CapacityLimited(msg) && !ContextOverflow(msg) retryable := CapacityLimited(msg) && !ContextOverflow(msg)
emitError(out, content.String(), retryable, msg) emitError(out, content.String(), retryable, msg, observation())
return return
} }
if chunk.Usage != nil { if chunk.Usage != nil {
@@ -124,7 +156,8 @@ func emitParsed(ctx context.Context, r io.Reader, out chan<- Event) {
content.WriteString(choice.Delta.Content) content.WriteString(choice.Delta.Content)
select { select {
case <-ctx.Done(): case <-ctx.Done():
emitAbort(out, content.String(), ctx.Err()) readOutcome = "cancelled"
emitAbort(out, content.String(), ctx.Err(), observation())
return return
case out <- Event{Kind: KindTextDelta, Text: choice.Delta.Content}: case out <- Event{Kind: KindTextDelta, Text: choice.Delta.Content}:
} }
@@ -139,12 +172,23 @@ func emitParsed(ctx context.Context, r io.Reader, out chan<- Event) {
reasoning.WriteString(choice.Delta.Reasoning) reasoning.WriteString(choice.Delta.Reasoning)
select { select {
case <-ctx.Done(): case <-ctx.Done():
emitAbort(out, content.String(), ctx.Err()) readOutcome = "cancelled"
emitAbort(out, content.String(), ctx.Err(), observation())
return return
case out <- Event{Kind: KindReasoningDelta, ReasoningDelta: choice.Delta.Reasoning}: case out <- Event{Kind: KindReasoningDelta, ReasoningDelta: choice.Delta.Reasoning}:
} }
} }
for _, tc := range choice.Delta.ToolCalls { for _, tc := range choice.Delta.ToolCalls {
if toolFragments < maxObservedToolFragments {
toolFragments++
}
if n := len(tc.Function.Arguments); n > 0 && toolArgumentBytes < maxObservedToolArgumentBytes {
if n > maxObservedToolArgumentBytes-toolArgumentBytes {
toolArgumentBytes = maxObservedToolArgumentBytes
} else {
toolArgumentBytes += n
}
}
cur, ok := calls[tc.Index] cur, ok := calls[tc.Index]
if !ok { if !ok {
cur = &ToolCall{} cur = &ToolCall{}
@@ -162,7 +206,8 @@ func emitParsed(ctx context.Context, r io.Reader, out chan<- Event) {
} }
select { select {
case <-ctx.Done(): case <-ctx.Done():
emitAbort(out, content.String(), ctx.Err()) readOutcome = "cancelled"
emitAbort(out, content.String(), ctx.Err(), observation())
return return
case out <- Event{ case out <- Event{
Kind: KindToolCallDelta, Kind: KindToolCallDelta,
@@ -175,21 +220,24 @@ func emitParsed(ctx context.Context, r io.Reader, out chan<- Event) {
} }
} }
if err := sc.Err(); err != nil { if err := sc.Err(); err != nil {
readOutcome = "read_error"
if ctx.Err() != nil { if ctx.Err() != nil {
emitAbort(out, content.String(), ctx.Err()) readOutcome = "cancelled"
emitAbort(out, content.String(), ctx.Err(), observation())
return return
} }
if sawUsage { if sawUsage {
sendTerminalFailure("stream read failed after reported usage") sendTerminalFailure("stream read failed after reported usage")
return return
} }
emitError(out, content.String(), true, err.Error()) emitError(out, content.String(), true, err.Error(), observation())
return return
} }
// 取消必须压过正常结束。select 在两个分支都就绪时随机挑选, // 取消必须压过正常结束。select 在两个分支都就绪时随机挑选,
// 竞态下会把 message_end 发给已经取消的调用方。 // 竞态下会把 message_end 发给已经取消的调用方。
if ctx.Err() != nil { if ctx.Err() != nil {
emitAbort(out, content.String(), ctx.Err()) readOutcome = "cancelled"
emitAbort(out, content.String(), ctx.Err(), observation())
return return
} }
// A finish reason alone does not prove the tool arguments arrived intact. // A finish reason alone does not prove the tool arguments arrived intact.
@@ -237,8 +285,9 @@ func emitParsed(ctx context.Context, r io.Reader, out chan<- Event) {
} }
select { select {
case <-ctx.Done(): case <-ctx.Done():
emitAbort(out, content.String(), ctx.Err()) readOutcome = "cancelled"
case out <- Event{Kind: KindMessageEnd, Message: msg, Usage: usage, FinishReason: finished, StreamComplete: sawDone}: emitAbort(out, content.String(), ctx.Err(), observation())
case out <- Event{Kind: KindMessageEnd, Message: msg, Usage: usage, FinishReason: finished, StreamComplete: sawDone, TerminalObservation: observation()}:
} }
} }
@@ -337,8 +386,8 @@ func mapFinish(reason string, hasTools bool) StopReason {
} }
} }
func emitError(out chan<- Event, partial string, retryable bool, message string) { func emitError(out chan<- Event, partial string, retryable bool, message string, observed ...*TerminalObservation) {
out <- Event{ event := Event{
Kind: KindError, Kind: KindError,
Retryable: retryable, Retryable: retryable,
ErrorMessage: message, ErrorMessage: message,
@@ -349,14 +398,18 @@ func emitError(out chan<- Event, partial string, retryable bool, message string)
StopReason: StopError, StopReason: StopError,
}, },
} }
if len(observed) > 0 {
event.TerminalObservation = observed[0]
}
out <- event
} }
func emitAbort(out chan<- Event, partial string, err error) { func emitAbort(out chan<- Event, partial string, err error, observed ...*TerminalObservation) {
msg := "aborted" msg := "aborted"
if err != nil { if err != nil {
msg = err.Error() msg = err.Error()
} }
out <- Event{ event := Event{
Kind: KindError, Kind: KindError,
Retryable: false, Retryable: false,
ErrorMessage: msg, ErrorMessage: msg,
@@ -367,6 +420,10 @@ func emitAbort(out chan<- Event, partial string, err error) {
StopReason: StopAborted, StopReason: StopAborted,
}, },
} }
if len(observed) > 0 {
event.TerminalObservation = observed[0]
}
out <- event
} }
// StreamError is a provider or transport failure encoded on the event stream. // StreamError is a provider or transport failure encoded on the event stream.
+80
View File
@@ -2,6 +2,7 @@ package llm
import ( import (
"context" "context"
"encoding/json"
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -87,6 +88,10 @@ func TestParseSSEToolCallIncrements(t *testing.T) {
if end.Message.StopReason != StopToolCalls || len(end.Message.ToolCalls) != 1 { if end.Message.StopReason != StopToolCalls || len(end.Message.ToolCalls) != 1 {
t.Fatalf("end %#v", end.Message) t.Fatalf("end %#v", end.Message)
} }
if end.TerminalObservation == nil || end.TerminalObservation.ReadOutcome != "done_marker" ||
!end.TerminalObservation.ToolArgumentsValid || end.TerminalObservation.ToolFragments != 3 {
t.Fatalf("valid multi-fragment terminal was not classified: %#v", end.TerminalObservation)
}
tc := end.Message.ToolCalls[0] tc := end.Message.ToolCalls[0]
if tc.ID != "call_1" || tc.Name != "read_file" || tc.Arguments != `{"path":"a.md"}` { if tc.ID != "call_1" || tc.Name != "read_file" || tc.Arguments != `{"path":"a.md"}` {
t.Fatalf("tool call %#v", tc) t.Fatalf("tool call %#v", tc)
@@ -168,6 +173,77 @@ func TestParseSSEToolFinishWithoutDone(t *testing.T) {
} }
} }
func TestTerminalObservationCleanEOFWithoutDone(t *testing.T) {
body := strings.Join([]string{
`data: {"choices":[{"delta":{"content":"PRIVATE_PROMPT_R05","tool_calls":[{"index":0,"id":"call_1","function":{"name":"PRIVATE_TOOL_R05","arguments":"{}"}}]}}]}`,
`data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":12,"completion_tokens":5,"total_tokens":17}}`,
"",
}, "\n\n")
var end Event
for ev := range ParseSSE(context.Background(), strings.NewReader(body)) {
if ev.Kind == KindMessageEnd {
end = ev
}
}
if end.Err == nil || end.StreamComplete || len(end.Message.ToolCalls) != 0 || end.Usage.TotalTokens != 17 {
t.Fatalf("unfinished tool stream was released or unmetered: %#v", end)
}
meta := end.TerminalObservation
if meta == nil || meta.ReadOutcome != "clean_eof" || !meta.LastFrameJSONComplete ||
!meta.UsagePresent || meta.ToolFragments != 1 || meta.ToolArgumentBytes != 2 || !meta.ToolArgumentsValid {
t.Fatalf("missing content-free clean EOF evidence: %#v", meta)
}
raw, err := json.Marshal(meta)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), "PRIVATE_PROMPT_R05") || strings.Contains(string(raw), "PRIVATE_TOOL_R05") {
t.Fatalf("terminal observation retained model content: %s", raw)
}
}
func TestTerminalObservationMalformedAndReadError(t *testing.T) {
usageAndTool := `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"echo","arguments":"{}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":12,"completion_tokens":5,"total_tokens":17}}` + "\n\n"
tests := []struct {
name string
ctx context.Context
reader io.Reader
outcome string
usage int
frameValid bool
}{
{"invalid-frame", context.Background(), strings.NewReader(usageAndTool + "data: {not-json}\n\n"), "invalid_frame", 17, false},
{"read-error", context.Background(), &errorAfterDataReader{data: strings.NewReader(usageAndTool)}, "read_error", 17, true},
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
tests = append(tests, struct {
name string
ctx context.Context
reader io.Reader
outcome string
usage int
frameValid bool
}{"cancelled", ctx, strings.NewReader(usageAndTool), "cancelled", 0, false})
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var end Event
for ev := range ParseSSE(tc.ctx, tc.reader) {
if ev.Kind == KindMessageEnd || ev.Kind == KindError {
end = ev
}
}
if end.Err == nil || len(end.Message.ToolCalls) != 0 || end.Usage.TotalTokens != tc.usage {
t.Fatalf("failure lost Usage or released tools: %#v", end)
}
meta := end.TerminalObservation
if meta == nil || meta.ReadOutcome != tc.outcome || meta.LastFrameJSONComplete != tc.frameValid {
t.Fatalf("wrong terminal classification: %#v", meta)
}
})
}
}
func TestParseSSEInvalidToolArguments(t *testing.T) { func TestParseSSEInvalidToolArguments(t *testing.T) {
body := strings.Join([]string{ body := strings.Join([]string{
`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"publish_work_checkpoint","arguments":"{\"findings\":"}}]}}]}`, `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"publish_work_checkpoint","arguments":"{\"findings\":"}}]}}]}`,
@@ -186,6 +262,10 @@ func TestParseSSEInvalidToolArguments(t *testing.T) {
end.Usage.TotalTokens != 17 { end.Usage.TotalTokens != 17 {
t.Fatalf("unsafe tool call with invalid JSON arguments: %#v", end) t.Fatalf("unsafe tool call with invalid JSON arguments: %#v", end)
} }
if end.TerminalObservation == nil || end.TerminalObservation.ToolArgumentsValid ||
end.TerminalObservation.ReadOutcome != "done_marker" {
t.Fatalf("invalid arguments incorrectly classified: %#v", end.TerminalObservation)
}
} }
func TestParseSSERejectsMissingToolIdentity(t *testing.T) { func TestParseSSERejectsMissingToolIdentity(t *testing.T) {
+19 -4
View File
@@ -98,6 +98,18 @@ type Usage struct {
LastTotalTokens int `json:"last_total_tokens,omitempty"` LastTotalTokens int `json:"last_total_tokens,omitempty"`
} }
// TerminalObservation records how a stream ended without retaining provider
// content, tool identity, arguments, request headers, or error text. Counts
// saturate at fixed limits; this evidence never authorizes a tool call.
type TerminalObservation struct {
ReadOutcome string `json:"readOutcome"`
LastFrameJSONComplete bool `json:"lastFrameJSONComplete"`
UsagePresent bool `json:"usagePresent"`
ToolFragments int `json:"toolFragments"`
ToolArgumentBytes int `json:"toolArgumentBytes"`
ToolArgumentsValid bool `json:"toolArgumentsValid"`
}
// Event is one streamed update. Failures are KindError plus a final assistant // Event is one streamed update. Failures are KindError plus a final assistant
// message whose StopReason is error or aborted. Stream implementations do not panic. // message whose StopReason is error or aborted. Stream implementations do not panic.
type Event struct { type Event struct {
@@ -116,10 +128,13 @@ type Event struct {
// StreamComplete records whether the OpenAI-compatible SSE stream supplied // StreamComplete records whether the OpenAI-compatible SSE stream supplied
// its explicit [DONE] marker. Tool calls require this before execution. // its explicit [DONE] marker. Tool calls require this before execution.
StreamComplete bool StreamComplete bool
Err error // TerminalObservation is present only on a terminal/error event. It does
Retryable bool // not change StreamComplete or the tool-execution safety gate.
ErrorMessage string TerminalObservation *TerminalObservation
// HTTPStatus 是提供方的 HTTP 状态。0 表示这条错误不是 HTTP 响应。 Err error
Retryable bool
ErrorMessage string
// HTTPStatus 是提供方的 HTTP 状态;0 表示尚无 HTTP 响应。
HTTPStatus int HTTPStatus int
// RetryAfter is the provider's requested wait. HasRetryAfter distinguishes // RetryAfter is the provider's requested wait. HasRetryAfter distinguishes
// a real zero from a missing or unparseable header. // a real zero from a missing or unparseable header.