first commit
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user