Files
HomeAgent/internal/events/bus.go
JianFeeeee 28a6d3f09c feat(agent): token-level streaming in core process loop
Replace the blocking Chat() call in process() with
chatStreamWithFallback: ChatStream first, accumulate chunks, fall back
to non-stream Chat on connect failure or empty-stream failure.

Why: the non-streaming path blocked for the ENTIRE LLM generation (up
to the 180s HTTP timeout). Reasoning models thinking 60-120s plus AUTO
chain failover regularly exceeded it -> context canceled -> full turn
wasted. With streaming the first chunk arrives in ~1-3s and any
flowing token keeps the connection alive; total generation time is no
longer bounded by an overall timeout.

Compatibility (external behavior unchanged):
  - process() signature/return values unchanged
  - Aggregated events (EventReasoning / EventAgentLLMChain) still fire
    once per turn with full text after stream completion - existing
    plugin subscribers see identical payloads as before
  - New incremental events EventReasoningDelta / EventContentDelta are
    additive; old subscribers ignore unknown event types
  - Tool execution loop, memory pipeline, stage pipeline untouched

Streaming details:
  - Tool call fragments accumulated per OpenAI streaming convention:
    id/name arrive on the first fragment, arguments as raw JSON string
    shards across fragments; merged and parsed once at stream end
  - normalizeStreamToolCalls keeps nameless argument shards (the
    non-stream normalizer drops them); ToolCall gains RawArguments to
    carry shard text
  - Interrupt mid-stream returns partial content instead of discarding
    the whole generation

Verified end-to-end against llmsproxy: plain chat streams correctly;
curl confirms tool-call shard wire format ({" + command" + :"date"}
-> {"command":"date"}); unit tests cover shard merging and
content/reasoning accumulation.
2026-08-25 09:30:49 +08:00

93 lines
2.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package events
import (
"fmt"
"log"
"sync"
)
type EventType string
const (
EventRawInput EventType = "raw_input"
EventAgentOutput EventType = "agent_output"
EventAgentLLMChain EventType = "agent_llm_chain"
EventToolCall EventType = "tool_call"
EventReasoning EventType = "reasoning"
EventStage EventType = "stage"
EventSystem EventType = "system"
EventTerminalOutput EventType = "terminal_output"
// 流式增量事件LLM token 级):核心改为流式后每收到一个增量块发布。
// 订阅者可选订不认识的旧订阅者自然忽略Bus 按 EventType 精确匹配分发)。
// 聚合事件 EventReasoning / EventAgentLLMChain 仍照常在每轮结束时全文发布,
// 插件体系行为不变。
EventReasoningDelta EventType = "reasoning_delta"
EventContentDelta EventType = "content_delta"
EventAll EventType = "*"
)
type Event struct {
Type EventType `json:"type"`
Source string `json:"source"`
Payload map[string]interface{} `json:"payload"`
Timestamp int64 `json:"timestamp"`
}
type Handler func(event *Event)
type Bus struct {
mu sync.RWMutex
subs map[EventType][]Handler
}
func NewBus() *Bus {
return &Bus{
subs: make(map[EventType][]Handler),
}
}
func (b *Bus) Publish(evt *Event) {
b.mu.RLock()
allHandlers := make([]Handler, len(b.subs[EventAll]))
copy(allHandlers, b.subs[EventAll])
typeHandlers := make([]Handler, len(b.subs[evt.Type]))
copy(typeHandlers, b.subs[evt.Type])
b.mu.RUnlock()
for _, h := range allHandlers {
b.safeCall(h, evt)
}
for _, h := range typeHandlers {
b.safeCall(h, evt)
}
}
func (b *Bus) safeCall(h Handler, evt *Event) {
defer func() {
if r := recover(); r != nil {
log.Printf("[bus] handler panic: %v", r)
}
}()
h(evt)
}
func (b *Bus) Subscribe(eventType EventType, handler Handler) func() {
b.mu.Lock()
b.subs[eventType] = append(b.subs[eventType], handler)
b.mu.Unlock()
return func() {
b.mu.Lock()
defer b.mu.Unlock()
list := b.subs[eventType]
for i, h := range list {
if fmt.Sprintf("%p", h) == fmt.Sprintf("%p", handler) {
b.subs[eventType] = append(list[:i], list[i+1:]...)
break
}
}
}
}