picoclaw/pkg/bus/bus.go
google-labs-jules[bot] 0d5d68ebd3 feat(agent): implement phase 1 of streaming responses
- Added `OutboundStreamMessage` type to represent streaming chunks.
- Added `outboundStream` channel to `MessageBus` with non-blocking send logic (`ErrBusFull`) to prevent stalling the LLM loop when consumers are unavailable or slow.
- Updated `processOptions` to include a `Stream` boolean.
- Updated `executeLLMWithRetry` to inject a `stream_callback` into `llmOpts` that pushes token chunks to `PublishOutboundStream`.
- Updated `AGENT_LOOP_IMPROVEMENTS.md` marking the task as complete.

Co-authored-by: hobbyistlabs-coder <267281733+hobbyistlabs-coder@users.noreply.github.com>
2026-03-18 08:27:10 +00:00

202 lines
4.2 KiB
Go

package bus
import (
"context"
"errors"
"sync/atomic"
"jane/pkg/logger"
)
// ErrBusClosed is returned when publishing to a closed MessageBus.
var ErrBusClosed = errors.New("message bus closed")
// ErrBusFull is returned when publishing to a full MessageBus.
var ErrBusFull = errors.New("message bus full")
const defaultBusBufferSize = 64
type MessageBus struct {
inbound chan InboundMessage
outbound chan OutboundMessage
outboundStream chan OutboundStreamMessage
outboundMedia chan OutboundMediaMessage
done chan struct{}
closed atomic.Bool
}
func NewMessageBus() *MessageBus {
return &MessageBus{
inbound: make(chan InboundMessage, defaultBusBufferSize),
outbound: make(chan OutboundMessage, defaultBusBufferSize),
outboundStream: make(chan OutboundStreamMessage, defaultBusBufferSize),
outboundMedia: make(chan OutboundMediaMessage, defaultBusBufferSize),
done: make(chan struct{}),
}
}
func (mb *MessageBus) PublishInbound(ctx context.Context, msg InboundMessage) error {
if mb.closed.Load() {
return ErrBusClosed
}
if err := ctx.Err(); err != nil {
return err
}
select {
case mb.inbound <- msg:
return nil
case <-mb.done:
return ErrBusClosed
case <-ctx.Done():
return ctx.Err()
}
}
func (mb *MessageBus) ConsumeInbound(ctx context.Context) (InboundMessage, bool) {
select {
case msg, ok := <-mb.inbound:
return msg, ok
case <-mb.done:
return InboundMessage{}, false
case <-ctx.Done():
return InboundMessage{}, false
}
}
func (mb *MessageBus) PublishOutbound(ctx context.Context, msg OutboundMessage) error {
if mb.closed.Load() {
return ErrBusClosed
}
if err := ctx.Err(); err != nil {
return err
}
select {
case mb.outbound <- msg:
return nil
case <-mb.done:
return ErrBusClosed
case <-ctx.Done():
return ctx.Err()
}
}
func (mb *MessageBus) SubscribeOutbound(ctx context.Context) (OutboundMessage, bool) {
select {
case msg, ok := <-mb.outbound:
return msg, ok
case <-mb.done:
return OutboundMessage{}, false
case <-ctx.Done():
return OutboundMessage{}, false
}
}
func (mb *MessageBus) PublishOutboundMedia(ctx context.Context, msg OutboundMediaMessage) error {
if mb.closed.Load() {
return ErrBusClosed
}
if err := ctx.Err(); err != nil {
return err
}
select {
case mb.outboundMedia <- msg:
return nil
case <-mb.done:
return ErrBusClosed
case <-ctx.Done():
return ctx.Err()
}
}
func (mb *MessageBus) SubscribeOutboundMedia(ctx context.Context) (OutboundMediaMessage, bool) {
select {
case msg, ok := <-mb.outboundMedia:
return msg, ok
case <-mb.done:
return OutboundMediaMessage{}, false
case <-ctx.Done():
return OutboundMediaMessage{}, false
}
}
func (mb *MessageBus) PublishOutboundStream(ctx context.Context, msg OutboundStreamMessage) error {
if mb.closed.Load() {
return ErrBusClosed
}
if err := ctx.Err(); err != nil {
return err
}
select {
case mb.outboundStream <- msg:
return nil
case <-mb.done:
return ErrBusClosed
case <-ctx.Done():
return ctx.Err()
default:
// Non-blocking send. If the buffer is full, immediately drop the token.
return ErrBusFull
}
}
func (mb *MessageBus) SubscribeOutboundStream(ctx context.Context) (OutboundStreamMessage, bool) {
select {
case msg, ok := <-mb.outboundStream:
return msg, ok
case <-mb.done:
return OutboundStreamMessage{}, false
case <-ctx.Done():
return OutboundStreamMessage{}, false
}
}
func (mb *MessageBus) Close() {
if mb.closed.CompareAndSwap(false, true) {
close(mb.done)
// Drain buffered channels so messages aren't silently lost.
// Channels are NOT closed to avoid send-on-closed panics from concurrent publishers.
drained := 0
for {
select {
case <-mb.inbound:
drained++
default:
goto doneInbound
}
}
doneInbound:
for {
select {
case <-mb.outbound:
drained++
default:
goto doneOutbound
}
}
doneOutbound:
for {
select {
case <-mb.outboundStream:
drained++
default:
goto doneStream
}
}
doneStream:
for {
select {
case <-mb.outboundMedia:
drained++
default:
goto doneMedia
}
}
doneMedia:
if drained > 0 {
logger.DebugCF("bus", "Drained buffered messages during close", map[string]any{
"count": drained,
})
}
}
}