From a5e3c7c817166865d75b764337b15ca7d505d131 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 14 Oct 2026 04:12:04 +0000 Subject: [PATCH] Reject legacy empty function-call terminals 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 --- runtime/llm/sse.go | 2 +- runtime/llm/sse_test.go | 32 ++++++++++++++++++-------------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/runtime/llm/sse.go b/runtime/llm/sse.go index 8ba0c4f..6edf9d2 100644 --- a/runtime/llm/sse.go +++ b/runtime/llm/sse.go @@ -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. // 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. - 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") return } diff --git a/runtime/llm/sse_test.go b/runtime/llm/sse_test.go index 48d28e4..3f79d8c 100644 --- a/runtime/llm/sse_test.go +++ b/runtime/llm/sse_test.go @@ -94,20 +94,24 @@ func TestParseSSEToolCallIncrements(t *testing.T) { } func TestParseSSEToolFinishWithoutCallFails(t *testing.T) { - body := strings.Join([]string{ - `data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":12,"completion_tokens":5,"total_tokens":17}}`, - `data: [DONE]`, - "", - }, "\n") - var end Event - 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) + for _, reason := range []string{"tool_calls", "function_call"} { + t.Run(reason, func(t *testing.T) { + body := strings.Join([]string{ + `data: {"choices":[{"delta":{},"finish_reason":"` + reason + `"}],"usage":{"prompt_tokens":12,"completion_tokens":5,"total_tokens":17}}`, + `data: [DONE]`, + "", + }, "\n") + var end Event + 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) + } + }) } }