Restructure the codebase so the CLI app consumes the SDK rather than the SDK wrapping CLI internals. This eliminates the circular dependency (sdk -> cmd -> sdk) and establishes pkg/kit as the canonical API. Key changes: - Create pkg/kit/ with InitConfig, SetupAgent, BuildProviderConfig extracted from cmd/root.go and cmd/setup.go as parameterized functions - Move sdk/kit.go -> pkg/kit/kit.go (remove cmd import, use local calls) - Move sdk/types.go -> pkg/kit/types.go - Move main.go -> cmd/kit/main.go (standard Go project layout) - cmd/root.go and cmd/setup.go now delegate to pkg/kit, injecting CLI-specific state (quietFlag) via the Quiet field on AgentSetupOptions - Add setSDKDefaults() for cobra-free SDK usage (viper defaults) - Fix .gitignore: kit -> /kit (was blocking cmd/kit/ and pkg/kit/) - Update .goreleaser.yaml, Taskfile.yml, AGENTS.md, contribute/build.sh, README.md for new cmd/kit entrypoint and pkg/kit import paths - Add plans/ with 10 detailed SDK revamp plans and Taskfile.yml - Delete sdk/ directory entirely
10 KiB
Plan 03: Event/Subscriber System
Priority: P1 Effort: High Goal: Create a unified event system in the SDK that replaces the three parallel event systems currently in the codebase
Background
Kit currently has three separate event systems that overlap:
- SDK callbacks (
sdk/kit.go) — 3 function pointers onPromptWithCallbacks - Extension events (
internal/extensions/events.go) — 13 typed events dispatched viaRunner.Emit() - App/TUI events (
internal/app/events.go) — 13tea.Msgstructs for BubbleTea UI updates
Pi uses a single session.subscribe(listener) pattern. This plan creates a unified event system in pkg/kit/ that:
- Replaces SDK callbacks
- Becomes the canonical event layer that extensions and the app emit through
- The TUI adapts SDK events into
tea.Msgfor rendering (TUI-specific concern stays ininternal/ui/)
Prerequisites
- Plan 00 (Create
pkg/kit/) - Plan 02 (Richer type exports)
Design Decisions
- Single source of truth — events are defined in
pkg/kit/, not scattered across packages - Multiple subscribers supported with unsubscribe
- Thread-safe emission
- App subscribes to SDK events — the TUI layer adapts them to
tea.Msg - Extensions emit through SDK — the extension runner emits SDK events, not its own types
Step-by-Step
Step 1: Define public event types
File: pkg/kit/events.go (new)
package kit
import "sync"
// EventType identifies the kind of event.
type EventType string
const (
EventTurnStart EventType = "turn_start"
EventTurnEnd EventType = "turn_end"
EventMessageStart EventType = "message_start"
EventMessageUpdate EventType = "message_update"
EventMessageEnd EventType = "message_end"
EventToolCall EventType = "tool_call"
EventToolExecutionStart EventType = "tool_execution_start"
EventToolExecutionEnd EventType = "tool_execution_end"
EventToolResult EventType = "tool_result"
EventToolCallContent EventType = "tool_call_content"
EventResponse EventType = "response"
EventSessionStart EventType = "session_start"
EventSessionShutdown EventType = "session_shutdown"
)
// Event is the interface for all event types.
type Event interface {
EventType() EventType
}
Step 2: Define concrete event structs
These cover the union of all three current event systems:
type TurnStartEvent struct{ Prompt string }
func (e TurnStartEvent) EventType() EventType { return EventTurnStart }
type TurnEndEvent struct{ Response string; Error error }
func (e TurnEndEvent) EventType() EventType { return EventTurnEnd }
type MessageStartEvent struct{}
func (e MessageStartEvent) EventType() EventType { return EventMessageStart }
type MessageUpdateEvent struct{ Chunk string }
func (e MessageUpdateEvent) EventType() EventType { return EventMessageUpdate }
type MessageEndEvent struct{ Content string }
func (e MessageEndEvent) EventType() EventType { return EventMessageEnd }
type ToolCallEvent struct{ ToolName string; ToolArgs string }
func (e ToolCallEvent) EventType() EventType { return EventToolCall }
type ToolExecutionStartEvent struct{ ToolName string }
func (e ToolExecutionStartEvent) EventType() EventType { return EventToolExecutionStart }
type ToolExecutionEndEvent struct{ ToolName string }
func (e ToolExecutionEndEvent) EventType() EventType { return EventToolExecutionEnd }
type ToolResultEvent struct{ ToolName, ToolArgs, Result string; IsError bool }
func (e ToolResultEvent) EventType() EventType { return EventToolResult }
type ToolCallContentEvent struct{ Content string }
func (e ToolCallContentEvent) EventType() EventType { return EventToolCallContent }
type ResponseEvent struct{ Content string }
func (e ResponseEvent) EventType() EventType { return EventResponse }
Step 3: Implement EventBus
type EventListener func(event Event)
type eventBus struct {
mu sync.RWMutex
listeners map[int]EventListener
nextID int
}
func newEventBus() *eventBus {
return &eventBus{listeners: make(map[int]EventListener)}
}
func (eb *eventBus) subscribe(listener EventListener) func() {
eb.mu.Lock()
id := eb.nextID
eb.nextID++
eb.listeners[id] = listener
eb.mu.Unlock()
return func() {
eb.mu.Lock()
delete(eb.listeners, id)
eb.mu.Unlock()
}
}
func (eb *eventBus) emit(event Event) {
eb.mu.RLock()
snapshot := make([]EventListener, 0, len(eb.listeners))
for _, l := range eb.listeners {
snapshot = append(snapshot, l)
}
eb.mu.RUnlock()
for _, l := range snapshot {
l(event)
}
}
Step 4: Wire EventBus into Kit struct
File: pkg/kit/kit.go
type Kit struct {
agent *agent.Agent
sessionMgr *session.Manager
modelString string
events *eventBus
}
func (m *Kit) Subscribe(listener EventListener) func() {
return m.events.subscribe(listener)
}
Step 5: Wire all agent callbacks to emit events
Update Prompt() and PromptWithCallbacks() to emit events at every stage of the agent generation flow. Events fire at these points (matching the lifecycle in internal/app/app.go:364-520):
- Before generation:
TurnStartEvent,MessageStartEvent - During streaming:
MessageUpdateEventper chunk - On tool call:
ToolCallEvent,ToolExecutionStartEvent - On tool result:
ToolExecutionEndEvent,ToolResultEvent - On response:
ResponseEvent - After generation:
MessageEndEvent,TurnEndEvent
Extract shared callback helpers to avoid duplication:
func (m *Kit) makeToolCallHandler() agent.ToolCallHandler {
return func(name, args string) {
m.events.emit(ToolCallEvent{ToolName: name, ToolArgs: args})
}
}
// ... similar for all callback types
Step 6: App-as-Consumer — TUI subscribes to SDK events
This is the critical refactor. Currently internal/app/app.go:executeStep() emits TUI events directly via sendFn(StreamChunkEvent{...}). After this change:
- The SDK's
Prompt()emits SDK events - The app subscribes to SDK events and converts them to
tea.Msg
File: internal/app/app.go (migration pattern)
// In App initialization, subscribe to SDK events and bridge to TUI
func (a *App) setupEventBridge() {
a.kit.Subscribe(func(e kit.Event) {
switch ev := e.(type) {
case kit.MessageUpdateEvent:
a.sendToTUI(StreamChunkEvent{Content: ev.Chunk})
case kit.ToolCallEvent:
a.sendToTUI(ToolCallStartedEvent{ToolName: ev.ToolName, ToolArgs: ev.ToolArgs})
case kit.ToolResultEvent:
a.sendToTUI(ToolResultEvent{
ToolName: ev.ToolName, ToolArgs: ev.ToolArgs,
Result: ev.Result, IsError: ev.IsError,
})
case kit.ResponseEvent:
a.sendToTUI(ResponseCompleteEvent{Content: ev.Content})
// ... etc
}
})
}
Migration steps:
- First: app subscribes to SDK events AND keeps its own emission (dual-emit phase)
- Then: remove direct emission from
executeStep(), rely solely on SDK events - Finally: remove
internal/app/events.gotypes that are now redundant
Step 7: Extension events bridge to SDK events
The extension Runner should emit through the SDK event bus rather than its own parallel system. This can be bridged:
// In Kit initialization, bridge extension events to SDK events
func (m *Kit) bridgeExtensionEvents(runner *extensions.Runner) {
// When extensions emit events, forward them as SDK events
// This is done by having the Runner call back into the SDK
runner.SetEventForwarder(func(event extensions.Event) {
switch e := event.(type) {
case extensions.ToolCallEvent:
m.events.emit(ToolCallEvent{ToolName: e.ToolName, ToolArgs: e.Input})
// ... etc
}
})
}
Note: This is a gradual migration. The extension Runner keeps its typed events for Yaegi compatibility, but forwards them to the SDK bus. Eventually the extension system could be refactored to emit SDK events natively.
Step 8: Typed convenience subscribers
func (m *Kit) OnToolCall(handler func(ToolCallEvent)) func() {
return m.Subscribe(func(e Event) {
if tc, ok := e.(ToolCallEvent); ok { handler(tc) }
})
}
func (m *Kit) OnToolResult(handler func(ToolResultEvent)) func() {
return m.Subscribe(func(e Event) {
if tr, ok := e.(ToolResultEvent); ok { handler(tr) }
})
}
func (m *Kit) OnStreaming(handler func(MessageUpdateEvent)) func() {
return m.Subscribe(func(e Event) {
if mu, ok := e.(MessageUpdateEvent); ok { handler(mu) }
})
}
Step 9: Write tests and verify
go build -o output/kit ./cmd/kit
go test -race ./...
go vet ./...
Files Changed Summary
| Action | File | Change |
|---|---|---|
| CREATE | pkg/kit/events.go |
Event types, EventBus, Subscribe() |
| EDIT | pkg/kit/kit.go |
Add eventBus field, Subscribe(), callback helpers |
| EDIT | internal/app/app.go |
Subscribe to SDK events (gradual migration) |
| EDIT | internal/extensions/runner.go |
Optional: event forwarding to SDK bus |
Event Flow After This Plan
Agent.GenerateWithLoopAndStreaming()
↓ fantasy callbacks
pkg/kit/kit.go (SDK Prompt method)
↓ emits SDK events
EventBus
↓ dispatches to all subscribers
├── External SDK user's listener
├── App TUI bridge → tea.Msg → BubbleTea Update()
└── Extension bridge → Runner.Emit() → Yaegi handlers
Single source of truth: The SDK EventBus is the only event dispatcher.
Verification Checklist
go build -o output/kit ./cmd/kitsucceedsgo test -race ./...passes- Events fire in correct order: TurnStart → MessageStart → updates → ToolCall → ToolResult → MessageEnd → TurnEnd
- Multiple subscribers receive all events
- Unsubscribe removes listener
- App TUI still renders correctly via event bridge
- Thread-safe under concurrent calls