140 lines
5.7 KiB
Go
140 lines
5.7 KiB
Go
package tui
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
|
|
tea "charm.land/bubbletea/v2"
|
|
|
|
"github.com/smallnest/pigo/internal/agentcore"
|
|
"github.com/smallnest/pigo/internal/runtime"
|
|
)
|
|
|
|
// This file bridges the agent run seam (runtime.StartRun + runtime.DrainStream)
|
|
// to Bubble Tea (US-004, SPEC 5.1 bridge / 3.2). The agent loop runs on its own
|
|
// goroutine and emits AgentEvents; a Bubble Tea program consumes tea.Msg values
|
|
// one at a time from its Update loop. The bridge is a pump: a goroutine drains
|
|
// the run and converts every event into the matching tea.Msg (see msgs.go),
|
|
// sending it into a buffered channel; a tea.Cmd (waitForEvent) receives one msg
|
|
// per Update tick. The channel is the only synchronization point, so the
|
|
// producer never touches the model and the model never touches the run — all
|
|
// state transitions happen on the tea goroutine.
|
|
//
|
|
// Back-pressure is intentional: the channel blocks the draining goroutine when
|
|
// the buffer is full, so no event is ever dropped (the tea loop always catches
|
|
// up). Node #388 wires startRun into Model.Init/Update; this file only provides
|
|
// the reusable, unit-testable primitives.
|
|
|
|
// eventChanCap is the buffer size of the bridge channel. A modest buffer lets a
|
|
// burst of tool events queue without blocking the run's goroutine on every send,
|
|
// while still bounding memory (blocking, never dropping, past the cap).
|
|
const eventChanCap = 64
|
|
|
|
// newEventChan allocates the buffered channel the bridge pumps run events
|
|
// through.
|
|
func newEventChan() chan tea.Msg {
|
|
return make(chan tea.Msg, eventChanCap)
|
|
}
|
|
|
|
// newStreamHandler builds the runtime.StreamHandler that converts each run event
|
|
// into a tea.Msg and sends it into ch. Sends block when ch is full, applying
|
|
// back-pressure to the draining goroutine so no event is lost. It is factored
|
|
// out of pump so the callback→msg conversion can be unit-tested without a real
|
|
// provider run (see bridge_test.go).
|
|
func newStreamHandler(ch chan tea.Msg, extra func(agentcore.AgentEvent)) runtime.StreamHandler {
|
|
return runtime.StreamHandler{
|
|
OnText: func(delta string) {
|
|
ch <- textDeltaMsg{delta: delta}
|
|
},
|
|
OnTurnEnd: func(msg agentcore.AssistantMessage, results []agentcore.ToolResultMessage) {
|
|
ch <- turnEndMsg{msg: msg, results: results}
|
|
},
|
|
OnEvent: func(ev agentcore.AgentEvent) {
|
|
// Deliver observer events (plugin notifier, SessionEnd/PreCompact hook)
|
|
// first, then translate into TUI messages.
|
|
if extra != nil {
|
|
extra(ev)
|
|
}
|
|
switch e := ev.(type) {
|
|
case agentcore.ToolExecutionStartEvent:
|
|
ch <- toolStartMsg{id: e.ToolCallID, name: e.ToolName, input: argsToMap(e.Args)}
|
|
case agentcore.ToolExecutionUpdateEvent:
|
|
ch <- toolUpdateMsg{id: e.ToolCallID, partial: agentcore.ContentToText(e.PartialResult.Content)}
|
|
case agentcore.ToolExecutionEndEvent:
|
|
ch <- toolEndMsg{id: e.ToolCallID, ok: !e.IsError, result: agentcore.ContentToText(e.Result.Content)}
|
|
case agentcore.SubAgentProgressEvent:
|
|
ch <- subagentProgressMsg{id: e.ToolCallID, desc: e.Description, activity: e.Activity, tokens: e.Tokens}
|
|
case agentcore.TelemetryEvent:
|
|
ch <- telemetryMsg{ev: e}
|
|
case agentcore.CompactionStartEvent:
|
|
ch <- compactionStartMsg{}
|
|
case agentcore.CompactionEvent:
|
|
ch <- compactionMsg{}
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
// argsToMap coerces a tool call's untyped Args into a map[string]any. The event
|
|
// layer carries Args as an untyped any: the tool executor emits it as a
|
|
// json.RawMessage (the raw decoded JSON arguments), but a caller may also hand
|
|
// an already-decoded map. Both are supported here so the tool card can show the
|
|
// call's arguments; anything that is not a JSON object yields nil.
|
|
func argsToMap(args any) map[string]any {
|
|
switch v := args.(type) {
|
|
case map[string]any:
|
|
return v
|
|
case json.RawMessage:
|
|
return unmarshalArgsMap(v)
|
|
case []byte:
|
|
return unmarshalArgsMap(v)
|
|
case string:
|
|
return unmarshalArgsMap([]byte(v))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// unmarshalArgsMap parses JSON object bytes into a map, returning nil for empty
|
|
// input or anything that is not a JSON object.
|
|
func unmarshalArgsMap(b []byte) map[string]any {
|
|
if len(b) == 0 {
|
|
return nil
|
|
}
|
|
var m map[string]any
|
|
if err := json.Unmarshal(b, &m); err != nil {
|
|
return nil
|
|
}
|
|
return m
|
|
}
|
|
|
|
// pump runs the agent loop to completion on the calling goroutine, converting
|
|
// every event to a tea.Msg on ch, and finally sends a runEndMsg carrying the
|
|
// run's result error. It is meant to be launched as a goroutine by startRun.
|
|
func pump(ctx context.Context, ch chan tea.Msg, agentCtx *agentcore.AgentContext, cfg runtime.RunConfig, onEvent func(agentcore.AgentEvent)) {
|
|
stream := runtime.StartRun(ctx, agentCtx, cfg)
|
|
_, err := runtime.DrainStream(ctx, stream, newStreamHandler(ch, onEvent))
|
|
ch <- runEndMsg{err: err}
|
|
}
|
|
|
|
// waitForEvent returns a tea.Cmd that blocks until the next bridge msg arrives.
|
|
// The Update loop re-issues it after handling each msg (except runEndMsg) to
|
|
// keep pulling events one at a time, so ordering is preserved and the tea
|
|
// goroutine never spins.
|
|
func waitForEvent(ch chan tea.Msg) tea.Cmd {
|
|
return func() tea.Msg {
|
|
return <-ch
|
|
}
|
|
}
|
|
|
|
// startRun launches the run pump on a new goroutine and returns the channel it
|
|
// feeds together with the first waitForEvent Cmd. The caller (node #388's model)
|
|
// stores the channel and, on every subsequent event, issues waitForEvent(ch)
|
|
// again to pull the next msg. Returning the channel keeps the bridge
|
|
// self-contained: the model owns the handle and decides when to stop pulling
|
|
// (after runEndMsg).
|
|
func startRun(ctx context.Context, agentCtx *agentcore.AgentContext, cfg runtime.RunConfig, onEvent func(agentcore.AgentEvent)) (chan tea.Msg, tea.Cmd) {
|
|
ch := newEventChan()
|
|
go pump(ctx, ch, agentCtx, cfg, onEvent)
|
|
return ch, waitForEvent(ch)
|
|
}
|