Files
Agent-System/internal/agentx/llm/sse.go
T
weichen 265616e997 feat(stream): 打通小队过程事件到持久化 detail 帧
pi-native 与 runner NDJSON 把推理和工具调用投影为 detail,任务流始终落库;非 200 的流式尝试回落到一次性 /v1/run,避免改变既有错误分类。
2026-09-28 10:30:52 +00:00

224 lines
5.3 KiB
Go

package llm
import (
"bufio"
"context"
"encoding/json"
"io"
"strings"
)
type sseChoice struct {
Delta struct {
Content string `json:"content"`
Reasoning string `json:"reasoning_content"`
ToolCalls []struct {
Index int `json:"index"`
ID string `json:"id"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
} `json:"delta"`
FinishReason string `json:"finish_reason"`
}
type sseChunk struct {
Choices []sseChoice `json:"choices"`
Usage *Usage `json:"usage"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
// ParseSSE reads an OpenAI-compatible server-sent event body and emits
// deltas plus one terminal message_end or error event.
func ParseSSE(ctx context.Context, r io.Reader) <-chan Event {
out := make(chan Event)
go func() {
defer close(out)
emitParsed(ctx, r, out)
}()
return out
}
func emitParsed(ctx context.Context, r io.Reader, out chan<- Event) {
var content strings.Builder
calls := map[int]*ToolCall{}
order := []int{}
var usage Usage
finished := ""
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for sc.Scan() {
if ctx.Err() != nil {
emitAbort(out, content.String(), ctx.Err())
return
}
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, ":") {
continue
}
if !strings.HasPrefix(line, "data:") {
continue
}
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if data == "[DONE]" {
break
}
var chunk sseChunk
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
emitError(out, content.String(), false, "malformed stream chunk: "+err.Error())
return
}
if chunk.Error != nil && chunk.Error.Message != "" {
emitError(out, content.String(), false, chunk.Error.Message)
return
}
if chunk.Usage != nil {
usage = *chunk.Usage
}
if len(chunk.Choices) == 0 {
continue
}
choice := chunk.Choices[0]
if choice.FinishReason != "" {
finished = choice.FinishReason
}
if choice.Delta.Content != "" {
content.WriteString(choice.Delta.Content)
select {
case <-ctx.Done():
emitAbort(out, content.String(), ctx.Err())
return
case out <- Event{Kind: KindTextDelta, Text: choice.Delta.Content}:
}
}
// reasoning_content(国产 OpenAI 兼容模型的思考链)与正文分流:
// 只作为 reasoning_delta 事件外发,不计入 Message.Content(与 runner
// stripThinkBlocks"正文无 think"语义对齐)。
if choice.Delta.Reasoning != "" {
select {
case <-ctx.Done():
emitAbort(out, content.String(), ctx.Err())
return
case out <- Event{Kind: KindReasoningDelta, ReasoningDelta: choice.Delta.Reasoning}:
}
}
for _, tc := range choice.Delta.ToolCalls {
cur, ok := calls[tc.Index]
if !ok {
cur = &ToolCall{}
calls[tc.Index] = cur
order = append(order, tc.Index)
}
if tc.ID != "" {
cur.ID = tc.ID
}
if tc.Function.Name != "" {
cur.Name += tc.Function.Name
}
if tc.Function.Arguments != "" {
cur.Arguments += tc.Function.Arguments
}
select {
case <-ctx.Done():
emitAbort(out, content.String(), ctx.Err())
return
case out <- Event{
Kind: KindToolCallDelta,
ToolIndex: tc.Index,
ToolID: tc.ID,
ToolName: tc.Function.Name,
ArgumentsDelta: tc.Function.Arguments,
}:
}
}
}
if err := sc.Err(); err != nil {
if ctx.Err() != nil {
emitAbort(out, content.String(), ctx.Err())
return
}
emitError(out, content.String(), true, err.Error())
return
}
msg := Message{Role: RoleAssistant, Content: content.String(), StopReason: mapFinish(finished, len(order) > 0)}
for _, idx := range order {
msg.ToolCalls = append(msg.ToolCalls, *calls[idx])
}
if msg.StopReason == StopToolCalls && len(msg.ToolCalls) == 0 {
msg.StopReason = StopEnd
}
select {
case <-ctx.Done():
emitAbort(out, content.String(), ctx.Err())
case out <- Event{Kind: KindMessageEnd, Message: msg, Usage: usage}:
}
}
func mapFinish(reason string, hasTools bool) StopReason {
switch reason {
case "tool_calls", "function_call":
return StopToolCalls
case "content_filter", "error":
return StopError
case "stop", "length", "":
if hasTools {
return StopToolCalls
}
return StopEnd
default:
if hasTools {
return StopToolCalls
}
return StopEnd
}
}
func emitError(out chan<- Event, partial string, retryable bool, message string) {
out <- Event{
Kind: KindError,
Retryable: retryable,
ErrorMessage: message,
Err: &StreamError{Message: message, Retryable: retryable},
Message: Message{
Role: RoleAssistant,
Content: partial,
StopReason: StopError,
},
}
}
func emitAbort(out chan<- Event, partial string, err error) {
msg := "aborted"
if err != nil {
msg = err.Error()
}
out <- Event{
Kind: KindError,
Retryable: false,
ErrorMessage: msg,
Err: err,
Message: Message{
Role: RoleAssistant,
Content: partial,
StopReason: StopAborted,
},
}
}
// StreamError is a provider or transport failure encoded on the event stream.
type StreamError struct {
Message string
Retryable bool
}
func (e *StreamError) Error() string {
if e == nil {
return "stream error"
}
return e.Message
}