feat: stream provider output to compatible channels

Prefer StreamingProvider responses in the agent loop when the active channel can consume incremental updates. Add Pico channel streaming primitives and message.delete handling so streamed turns can render progressively in the web UI while still preserving final LLM responses for normal turn completion.
This commit is contained in:
alanbulan 2026-04-07 22:40:10 +08:00
parent 6ce0306c66
commit 15888963ea
8 changed files with 350 additions and 9 deletions

View file

@ -2004,6 +2004,61 @@ turnLoop:
"tools_json": formatToolsForLog(providerToolDefs), "tools_json": formatToolsForLog(providerToolDefs),
}) })
callProvider := func(
ctx context.Context,
provider providers.LLMProvider,
model string,
messagesForCall []providers.Message,
toolDefsForCall []providers.ToolDefinition,
) (*providers.LLMResponse, error) {
if streamingProvider, ok := provider.(providers.StreamingProvider); ok {
if streamer, streamOK := al.bus.GetStreamer(ctx, ts.channel, ts.chatID); streamOK {
var finalized bool
response, err := streamingProvider.ChatStream(
ctx,
messagesForCall,
toolDefsForCall,
model,
llmOpts,
func(accumulated string) {
if accumulated == "" {
return
}
if err := streamer.Update(ctx, accumulated); err != nil {
logger.DebugCF("agent", "Streaming update failed", map[string]any{
"agent_id": ts.agent.ID,
"channel": ts.channel,
"chat_id": ts.chatID,
"error": err.Error(),
})
}
},
)
if err != nil {
streamer.Cancel(ctx)
return nil, err
}
if response != nil && response.Content != "" {
if err := streamer.Finalize(ctx, response.Content); err != nil {
logger.DebugCF("agent", "Streaming finalize failed", map[string]any{
"agent_id": ts.agent.ID,
"channel": ts.channel,
"chat_id": ts.chatID,
"error": err.Error(),
})
} else {
finalized = true
}
}
if !finalized && response != nil {
streamer.Cancel(ctx)
}
return response, nil
}
}
return provider.Chat(ctx, messagesForCall, toolDefsForCall, model, llmOpts)
}
callLLM := func(messagesForCall []providers.Message, toolDefsForCall []providers.ToolDefinition) (*providers.LLMResponse, error) { callLLM := func(messagesForCall []providers.Message, toolDefsForCall []providers.ToolDefinition) (*providers.LLMResponse, error) {
providerCtx, providerCancel := context.WithCancel(turnCtx) providerCtx, providerCancel := context.WithCancel(turnCtx)
ts.setProviderCancel(providerCancel) ts.setProviderCancel(providerCancel)
@ -2024,7 +2079,7 @@ turnLoop:
if cp, ok := ts.agent.CandidateProviders[providers.ModelKey(provider, model)]; ok { if cp, ok := ts.agent.CandidateProviders[providers.ModelKey(provider, model)]; ok {
candidateProvider = cp candidateProvider = cp
} }
return candidateProvider.Chat(ctx, messagesForCall, toolDefsForCall, model, llmOpts) return callProvider(ctx, candidateProvider, model, messagesForCall, toolDefsForCall)
}, },
) )
if fbErr != nil { if fbErr != nil {
@ -2040,7 +2095,7 @@ turnLoop:
} }
return fbResult.Response, nil return fbResult.Response, nil
} }
return activeProvider.Chat(providerCtx, messagesForCall, toolDefsForCall, llmModel, llmOpts) return callProvider(providerCtx, activeProvider, llmModel, messagesForCall, toolDefsForCall)
} }
var response *providers.LLMResponse var response *providers.LLMResponse

View file

@ -41,6 +41,38 @@ type fakeMediaChannel struct {
sentMedia []bus.OutboundMediaMessage sentMedia []bus.OutboundMediaMessage
} }
type fakeStreamer struct {
updates []string
finalized []string
cancelled bool
}
func (s *fakeStreamer) Update(ctx context.Context, content string) error {
s.updates = append(s.updates, content)
return nil
}
func (s *fakeStreamer) Finalize(ctx context.Context, content string) error {
s.finalized = append(s.finalized, content)
return nil
}
func (s *fakeStreamer) Cancel(ctx context.Context) {
s.cancelled = true
}
type fakeStreamingChannel struct {
fakeChannel
streamer *fakeStreamer
}
func (f *fakeStreamingChannel) BeginStream(ctx context.Context, chatID string) (channels.Streamer, error) {
if f.streamer == nil {
f.streamer = &fakeStreamer{}
}
return f.streamer, nil
}
func (f *fakeMediaChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { func (f *fakeMediaChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) {
f.sentMedia = append(f.sentMedia, msg) f.sentMedia = append(f.sentMedia, msg)
return nil, nil return nil, nil
@ -2626,6 +2658,45 @@ func TestProcessMessage_PublishesReasoningContentToReasoningChannel(t *testing.T
} }
} }
func TestProcessMessage_StreamsToChannelWhenProviderSupportsStreaming(t *testing.T) {
tmpDir := t.TempDir()
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
},
}
msgBus := bus.NewMessageBus()
provider := &mockProvider{streamChunks: []string{"hel", "lo"}}
al := NewAgentLoop(cfg, msgBus, provider)
streamingChannel := &fakeStreamingChannel{fakeChannel: fakeChannel{id: "reason-chat"}, streamer: &fakeStreamer{}}
al.SetChannelManager(newStartedTestChannelManager(t, msgBus, nil, "pico", streamingChannel))
response, err := al.processMessage(context.Background(), bus.InboundMessage{
Channel: "pico",
SenderID: "user1",
ChatID: "chat1",
Content: "hello",
})
if err != nil {
t.Fatalf("processMessage() error = %v", err)
}
if response != "hello" {
t.Fatalf("processMessage() response = %q, want %q", response, "hello")
}
if got := streamingChannel.streamer.updates; len(got) != 2 || got[0] != "hel" || got[1] != "hello" {
t.Fatalf("stream updates = %v, want [hel hello]", got)
}
if got := streamingChannel.streamer.finalized; len(got) != 1 || got[0] != "hello" {
t.Fatalf("stream finalized = %v, want [hello]", got)
}
}
func TestProcessHeartbeat_DoesNotPublishToolFeedback(t *testing.T) { func TestProcessHeartbeat_DoesNotPublishToolFeedback(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
heartbeatFile := filepath.Join(tmpDir, "heartbeat-task.txt") heartbeatFile := filepath.Join(tmpDir, "heartbeat-task.txt")

View file

@ -6,7 +6,9 @@ import (
"github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/providers"
) )
type mockProvider struct{} type mockProvider struct {
streamChunks []string
}
func (m *mockProvider) Chat( func (m *mockProvider) Chat(
ctx context.Context, ctx context.Context,
@ -24,3 +26,30 @@ func (m *mockProvider) Chat(
func (m *mockProvider) GetDefaultModel() string { func (m *mockProvider) GetDefaultModel() string {
return "mock-model" return "mock-model"
} }
func (m *mockProvider) ChatStream(
ctx context.Context,
messages []providers.Message,
tools []providers.ToolDefinition,
model string,
opts map[string]any,
onChunk func(accumulated string),
) (*providers.LLMResponse, error) {
accumulated := ""
for _, chunk := range m.streamChunks {
accumulated += chunk
if onChunk != nil {
onChunk(accumulated)
}
}
if accumulated == "" {
accumulated = "Mock response"
if onChunk != nil {
onChunk(accumulated)
}
}
return &providers.LLMResponse{
Content: accumulated,
ToolCalls: []providers.ToolCall{},
}, nil
}

View file

@ -23,12 +23,13 @@ import (
// picoConn represents a single WebSocket connection. // picoConn represents a single WebSocket connection.
type picoConn struct { type picoConn struct {
id string id string
conn *websocket.Conn conn *websocket.Conn
sessionID string sessionID string
writeMu sync.Mutex writeMu sync.Mutex
closed atomic.Bool closed atomic.Bool
cancel context.CancelFunc // cancels per-connection goroutines (e.g. pingLoop) cancel context.CancelFunc // cancels per-connection goroutines (e.g. pingLoop)
writeJSONFunc func(any) error
} }
var allowedInlineImageMIMETypes = map[string]struct{}{ var allowedInlineImageMIMETypes = map[string]struct{}{
@ -46,6 +47,9 @@ func (pc *picoConn) writeJSON(v any) error {
} }
pc.writeMu.Lock() pc.writeMu.Lock()
defer pc.writeMu.Unlock() defer pc.writeMu.Unlock()
if pc.writeJSONFunc != nil {
return pc.writeJSONFunc(v)
}
return pc.conn.WriteJSON(v) return pc.conn.WriteJSON(v)
} }
@ -72,6 +76,14 @@ type PicoChannel struct {
cancel context.CancelFunc cancel context.CancelFunc
} }
type picoStreamer struct {
channel *PicoChannel
chatID string
messageID string
content string
mu sync.Mutex
}
// NewPicoChannel creates a new Pico Protocol channel. // NewPicoChannel creates a new Pico Protocol channel.
func NewPicoChannel(cfg config.PicoConfig, messageBus *bus.MessageBus) (*PicoChannel, error) { func NewPicoChannel(cfg config.PicoConfig, messageBus *bus.MessageBus) (*PicoChannel, error) {
if cfg.Token.String() == "" { if cfg.Token.String() == "" {
@ -255,6 +267,28 @@ func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]stri
return nil, c.broadcastToSession(msg.ChatID, outMsg) return nil, c.broadcastToSession(msg.ChatID, outMsg)
} }
// BeginStream implements channels.StreamingCapable.
func (c *PicoChannel) BeginStream(ctx context.Context, chatID string) (channels.Streamer, error) {
if !c.IsRunning() {
return nil, channels.ErrNotRunning
}
messageID := uuid.New().String()
outMsg := newMessage(TypeMessageCreate, map[string]any{
"content": "",
"message_id": messageID,
})
if err := c.broadcastToSession(chatID, outMsg); err != nil {
return nil, err
}
return &picoStreamer{
channel: c,
chatID: chatID,
messageID: messageID,
}, nil
}
// EditMessage implements channels.MessageEditor. // EditMessage implements channels.MessageEditor.
func (c *PicoChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error { func (c *PicoChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {
outMsg := newMessage(TypeMessageUpdate, map[string]any{ outMsg := newMessage(TypeMessageUpdate, map[string]any{
@ -264,6 +298,14 @@ func (c *PicoChannel) EditMessage(ctx context.Context, chatID string, messageID
return c.broadcastToSession(chatID, outMsg) return c.broadcastToSession(chatID, outMsg)
} }
// DeleteMessage implements channels.MessageDeleter.
func (c *PicoChannel) DeleteMessage(ctx context.Context, chatID string, messageID string) error {
outMsg := newMessage(TypeMessageDelete, map[string]any{
"message_id": messageID,
})
return c.broadcastToSession(chatID, outMsg)
}
// StartTyping implements channels.TypingCapable. // StartTyping implements channels.TypingCapable.
func (c *PicoChannel) StartTyping(ctx context.Context, chatID string) (func(), error) { func (c *PicoChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
startMsg := newMessage(TypeTypingStart, nil) startMsg := newMessage(TypeTypingStart, nil)
@ -276,6 +318,24 @@ func (c *PicoChannel) StartTyping(ctx context.Context, chatID string) (func(), e
}, nil }, nil
} }
func (s *picoStreamer) Update(ctx context.Context, content string) error {
s.mu.Lock()
defer s.mu.Unlock()
s.content = content
return s.channel.EditMessage(ctx, s.chatID, s.messageID, content)
}
func (s *picoStreamer) Finalize(ctx context.Context, content string) error {
s.mu.Lock()
defer s.mu.Unlock()
s.content = content
return s.channel.EditMessage(ctx, s.chatID, s.messageID, content)
}
func (s *picoStreamer) Cancel(ctx context.Context) {
_ = s.channel.DeleteMessage(ctx, s.chatID, s.messageID)
}
// SendPlaceholder implements channels.PlaceholderCapable. // SendPlaceholder implements channels.PlaceholderCapable.
// It sends a placeholder message via the Pico Protocol that will later be // It sends a placeholder message via the Pico Protocol that will later be
// edited to the actual response via EditMessage (channels.MessageEditor). // edited to the actual response via EditMessage (channels.MessageEditor).

View file

@ -23,6 +23,7 @@ func newTestPicoChannel(t *testing.T) *PicoChannel {
} }
ch.ctx = context.Background() ch.ctx = context.Background()
ch.SetRunning(true)
return ch return ch
} }
@ -122,6 +123,88 @@ func TestBroadcastToSession_TargetsOnlyRequestedSession(t *testing.T) {
} }
} }
func TestDeleteMessage_SendsDeleteEvent(t *testing.T) {
ch := newTestPicoChannel(t)
messages := make([]PicoMessage, 0, 1)
conn := &picoConn{
id: "delete",
sessionID: "sess-del",
writeJSONFunc: func(v any) error {
msg, ok := v.(PicoMessage)
if ok {
messages = append(messages, msg)
}
return nil
},
}
ch.addConnForTest(conn)
if err := ch.DeleteMessage(context.Background(), "pico:sess-del", "msg-1"); err != nil {
t.Fatalf("DeleteMessage() error = %v", err)
}
if len(messages) != 1 {
t.Fatalf("expected 1 delete message, got %d", len(messages))
}
if messages[0].Type != TypeMessageDelete {
t.Fatalf("message type = %q, want %q", messages[0].Type, TypeMessageDelete)
}
if messages[0].Payload["message_id"] != "msg-1" {
t.Fatalf("message_id = %v, want msg-1", messages[0].Payload["message_id"])
}
}
func TestBeginStream_SendsCreateAndUpdatesMessage(t *testing.T) {
ch := newTestPicoChannel(t)
messages := make([]PicoMessage, 0, 3)
conn := &picoConn{
id: "stream",
sessionID: "sess-1",
writeJSONFunc: func(v any) error {
msg, ok := v.(PicoMessage)
if ok {
messages = append(messages, msg)
}
return nil
},
}
ch.addConnForTest(conn)
streamer, err := ch.BeginStream(context.Background(), "pico:sess-1")
if err != nil {
t.Fatalf("BeginStream() error = %v", err)
}
if len(messages) == 0 {
t.Fatal("expected initial message.create for stream placeholder")
}
createPayload := messages[0].Payload
messageID, _ := createPayload["message_id"].(string)
if messageID == "" {
t.Fatal("expected message_id in initial stream create payload")
}
if err := streamer.Update(context.Background(), "hello"); err != nil {
t.Fatalf("Update() error = %v", err)
}
if err := streamer.Finalize(context.Background(), "hello world"); err != nil {
t.Fatalf("Finalize() error = %v", err)
}
if len(messages) < 3 {
t.Fatalf("expected at least 3 messages, got %d", len(messages))
}
updatePayload := messages[1].Payload
if updatePayload["message_id"] != messageID {
t.Fatalf("update message_id = %v, want %s", updatePayload["message_id"], messageID)
}
if updatePayload["content"] != "hello" {
t.Fatalf("update content = %v, want hello", updatePayload["content"])
}
finalPayload := messages[2].Payload
if finalPayload["content"] != "hello world" {
t.Fatalf("final content = %v, want hello world", finalPayload["content"])
}
}
func (c *PicoChannel) addConnForTest(pc *picoConn) { func (c *PicoChannel) addConnForTest(pc *picoConn) {
c.connsMu.Lock() c.connsMu.Lock()
defer c.connsMu.Unlock() defer c.connsMu.Unlock()

View file

@ -12,6 +12,7 @@ const (
// TypeMessageCreate is sent from server to client. // TypeMessageCreate is sent from server to client.
TypeMessageCreate = "message.create" TypeMessageCreate = "message.create"
TypeMessageUpdate = "message.update" TypeMessageUpdate = "message.update"
TypeMessageDelete = "message.delete"
TypeMediaCreate = "media.create" TypeMediaCreate = "media.create"
TypeTypingStart = "typing.start" TypeTypingStart = "typing.start"
TypeTypingStop = "typing.stop" TypeTypingStop = "typing.stop"

View file

@ -764,6 +764,36 @@ func TestProviderChat_CustomHeadersInjected(t *testing.T) {
} }
} }
func TestProviderChatStream_ParsesTextDeltas(t *testing.T) {
var chunks []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"hel\"},\"finish_reason\":null}]}\n\n"))
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"lo\"},\"finish_reason\":\"stop\"}]}\n\n"))
_, _ = w.Write([]byte("data: [DONE]\n\n"))
}))
defer server.Close()
p := NewProvider("key", server.URL, "")
out, err := p.ChatStream(
t.Context(),
[]Message{{Role: "user", Content: "hi"}},
nil,
"gpt-4o",
nil,
func(accumulated string) { chunks = append(chunks, accumulated) },
)
if err != nil {
t.Fatalf("ChatStream() error = %v", err)
}
if out.Content != "hello" {
t.Fatalf("Content = %q, want %q", out.Content, "hello")
}
if len(chunks) != 2 || chunks[0] != "hel" || chunks[1] != "hello" {
t.Fatalf("chunks = %v, want [hel hello]", chunks)
}
}
func TestProviderChatStream_CustomHeadersInjected(t *testing.T) { func TestProviderChatStream_CustomHeadersInjected(t *testing.T) {
var gotSource, gotAuth, gotUserAgent string var gotSource, gotAuth, gotUserAgent string

View file

@ -61,6 +61,18 @@ export function handlePicoMessage(
break break
} }
case "message.delete": {
const messageId = payload.message_id as string
if (!messageId) {
break
}
updateChatStore((prev) => ({
messages: prev.messages.filter((msg) => msg.id !== messageId),
}))
break
}
case "typing.start": case "typing.start":
updateChatStore({ isTyping: true }) updateChatStore({ isTyping: true })
break break