Compare commits

...
1 Commits
Author SHA1 Message Date
weichen a5e3c7c817 Reject legacy empty function-call terminals
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
ci / vet-and-test (pull_request) Successful in 55s
Why: The parser also recognizes function_call as a tool finish, but cannot execute a legacy call when no deltas were collected. It must fail closed like tool_calls.

Evidence: The function_call variant failed before this guard and passed afterward; full Agent-Native vet/test passed.

Impact: Inconsistent legacy tool terminals fail with returned Usage preserved; valid streams are unchanged.

Refs: U3 r0.4 independent review Important 2 closure
2026-10-14 04:12:04 +00:00
2 changed files with 19 additions and 15 deletions
+1 -1
View File
@@ -195,7 +195,7 @@ func emitParsed(ctx context.Context, r io.Reader, out chan<- Event) {
// A finish reason alone does not prove the tool arguments arrived intact. // A finish reason alone does not prove the tool arguments arrived intact.
// Preserve returned usage when rejecting a partial terminal, so the host // Preserve returned usage when rejecting a partial terminal, so the host
// can charge this call and stop rather than treating it as a free retry. // can charge this call and stop rather than treating it as a free retry.
if finished == "tool_calls" && len(order) == 0 { if (finished == "tool_calls" || finished == "function_call") && len(order) == 0 {
sendTerminalFailure("tool call finish reason without a tool call") sendTerminalFailure("tool call finish reason without a tool call")
return return
} }
+18 -14
View File
@@ -94,20 +94,24 @@ func TestParseSSEToolCallIncrements(t *testing.T) {
} }
func TestParseSSEToolFinishWithoutCallFails(t *testing.T) { func TestParseSSEToolFinishWithoutCallFails(t *testing.T) {
body := strings.Join([]string{ for _, reason := range []string{"tool_calls", "function_call"} {
`data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":12,"completion_tokens":5,"total_tokens":17}}`, t.Run(reason, func(t *testing.T) {
`data: [DONE]`, body := strings.Join([]string{
"", `data: {"choices":[{"delta":{},"finish_reason":"` + reason + `"}],"usage":{"prompt_tokens":12,"completion_tokens":5,"total_tokens":17}}`,
}, "\n") `data: [DONE]`,
var end Event "",
for ev := range ParseSSE(context.Background(), strings.NewReader(body)) { }, "\n")
if ev.Kind == KindMessageEnd { var end Event
end = ev for ev := range ParseSSE(context.Background(), strings.NewReader(body)) {
} if ev.Kind == KindMessageEnd {
} end = ev
if end.Err == nil || end.Message.StopReason != StopError || end.Usage.TotalTokens != 17 || }
!end.StreamComplete || len(end.Message.ToolCalls) != 0 { }
t.Fatalf("empty tool finish must fail and retain Usage: %#v", end) if end.Err == nil || end.Message.StopReason != StopError || end.Usage.TotalTokens != 17 ||
!end.StreamComplete || len(end.Message.ToolCalls) != 0 {
t.Fatalf("empty tool finish must fail and retain Usage: %#v", end)
}
})
} }
} }