mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 03:30:26 +00:00
remove dead code and add Plan 10 for full app-as-SDK-consumer integration
Delete legacy session files (manager.go, session.go) and unused ParseModelName() — all orphaned after Plans 00-09. Add Plan 10 to close all deferred items: app uses kit.New(), executeStep() delegates to kit.PromptResult(), extension observation events route through SDK EventBus.
This commit is contained in:
@@ -81,15 +81,3 @@ func CreateAgent(ctx context.Context, opts *AgentCreationOptions) (*Agent, error
|
||||
|
||||
return agent, nil
|
||||
}
|
||||
|
||||
// ParseModelName extracts provider and model name from a model string.
|
||||
// Model strings are formatted as "provider/model" (e.g., "anthropic/claude-sonnet-4-5-20250929").
|
||||
// The legacy "provider:model" format is also accepted for backward compatibility.
|
||||
// If the string cannot be parsed, returns "unknown" for both provider and model.
|
||||
func ParseModelName(modelString string) (provider, model string) {
|
||||
p, m, err := models.ParseModelString(modelString)
|
||||
if err != nil {
|
||||
return "unknown", "unknown"
|
||||
}
|
||||
return p, m
|
||||
}
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"charm.land/fantasy"
|
||||
|
||||
"github.com/mark3labs/kit/internal/message"
|
||||
)
|
||||
|
||||
// Manager manages session state and auto-saving functionality.
|
||||
// It provides thread-safe operations for managing a conversation session,
|
||||
// including automatic persistence to disk after each modification.
|
||||
type Manager struct {
|
||||
session *Session
|
||||
filePath string
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
// NewManager creates a new session manager with a fresh session.
|
||||
func NewManager(filePath string) *Manager {
|
||||
return &Manager{
|
||||
session: NewSession(),
|
||||
filePath: filePath,
|
||||
}
|
||||
}
|
||||
|
||||
// NewManagerWithSession creates a new session manager with an existing session.
|
||||
func NewManagerWithSession(session *Session, filePath string) *Manager {
|
||||
return &Manager{
|
||||
session: session,
|
||||
filePath: filePath,
|
||||
}
|
||||
}
|
||||
|
||||
// AddMessage adds a fantasy message to the session and auto-saves.
|
||||
func (m *Manager) AddMessage(msg fantasy.Message) error {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
|
||||
sessionMsg := ConvertFromFantasyMessage(msg)
|
||||
m.session.AddMessage(sessionMsg)
|
||||
|
||||
if m.filePath != "" {
|
||||
return m.session.SaveToFile(m.filePath)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddMessages adds multiple fantasy messages to the session and auto-saves.
|
||||
func (m *Manager) AddMessages(msgs []fantasy.Message) error {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
|
||||
for _, msg := range msgs {
|
||||
sessionMsg := ConvertFromFantasyMessage(msg)
|
||||
m.session.AddMessage(sessionMsg)
|
||||
}
|
||||
|
||||
if m.filePath != "" {
|
||||
return m.session.SaveToFile(m.filePath)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReplaceAllMessages replaces all messages in the session with the provided messages.
|
||||
func (m *Manager) ReplaceAllMessages(msgs []fantasy.Message) error {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
|
||||
// Clear existing messages
|
||||
m.session.Messages = []message.Message{}
|
||||
|
||||
// Add all new messages
|
||||
for _, msg := range msgs {
|
||||
sessionMsg := ConvertFromFantasyMessage(msg)
|
||||
m.session.AddMessage(sessionMsg)
|
||||
}
|
||||
|
||||
if m.filePath != "" {
|
||||
return m.session.SaveToFile(m.filePath)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetMetadata sets the session metadata.
|
||||
func (m *Manager) SetMetadata(metadata Metadata) error {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
|
||||
m.session.SetMetadata(metadata)
|
||||
|
||||
if m.filePath != "" {
|
||||
return m.session.SaveToFile(m.filePath)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetMessages returns all messages as fantasy.Message slice.
|
||||
func (m *Manager) GetMessages() []fantasy.Message {
|
||||
m.mutex.RLock()
|
||||
defer m.mutex.RUnlock()
|
||||
|
||||
var messages []fantasy.Message
|
||||
for _, msg := range m.session.Messages {
|
||||
messages = append(messages, msg.ToFantasyMessages()...)
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
// GetSession returns a copy of the current session.
|
||||
func (m *Manager) GetSession() *Session {
|
||||
m.mutex.RLock()
|
||||
defer m.mutex.RUnlock()
|
||||
|
||||
sessionCopy := *m.session
|
||||
sessionCopy.Messages = make([]message.Message, len(m.session.Messages))
|
||||
copy(sessionCopy.Messages, m.session.Messages)
|
||||
|
||||
return &sessionCopy
|
||||
}
|
||||
|
||||
// Save manually saves the session to file.
|
||||
func (m *Manager) Save() error {
|
||||
m.mutex.RLock()
|
||||
defer m.mutex.RUnlock()
|
||||
|
||||
if m.filePath == "" {
|
||||
return fmt.Errorf("no file path specified for session manager")
|
||||
}
|
||||
|
||||
return m.session.SaveToFile(m.filePath)
|
||||
}
|
||||
|
||||
// GetFilePath returns the file path for this session.
|
||||
func (m *Manager) GetFilePath() string {
|
||||
return m.filePath
|
||||
}
|
||||
|
||||
// MessageCount returns the number of messages in the session.
|
||||
func (m *Manager) MessageCount() int {
|
||||
m.mutex.RLock()
|
||||
defer m.mutex.RUnlock()
|
||||
|
||||
return len(m.session.Messages)
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"charm.land/fantasy"
|
||||
|
||||
"github.com/mark3labs/kit/internal/message"
|
||||
)
|
||||
|
||||
// Session represents a complete conversation session with metadata.
|
||||
// It stores all messages exchanged during a conversation along with
|
||||
// contextual information about the session such as the provider, model,
|
||||
// and timestamps. Sessions can be saved to and loaded from JSON files
|
||||
// for persistence across program runs.
|
||||
type Session struct {
|
||||
// Version indicates the session format version for compatibility
|
||||
Version string `json:"version"`
|
||||
// CreatedAt is the timestamp when the session was first created
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
// UpdatedAt is the timestamp when the session was last modified
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
// Metadata contains contextual information about the session
|
||||
Metadata Metadata `json:"metadata"`
|
||||
// Messages is the ordered list of all messages in this session, stored
|
||||
// as custom content blocks (crush-style). Each message contains a
|
||||
// heterogeneous Parts slice serialized as type-tagged JSON.
|
||||
Messages []message.Message `json:"messages"`
|
||||
}
|
||||
|
||||
// Metadata contains session metadata that provides context about the
|
||||
// environment and configuration used during the conversation.
|
||||
type Metadata struct {
|
||||
// KitVersion is the version of KIT used for this session
|
||||
KitVersion string `json:"kit_version"`
|
||||
// Provider is the LLM provider used (e.g., "anthropic", "openai", "gemini")
|
||||
Provider string `json:"provider"`
|
||||
// Model is the specific model identifier used for the conversation
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
// NewSession creates a new session with default values.
|
||||
func NewSession() *Session {
|
||||
return &Session{
|
||||
Version: "2.0",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
Messages: []message.Message{},
|
||||
Metadata: Metadata{},
|
||||
}
|
||||
}
|
||||
|
||||
// AddMessage adds a message to the session.
|
||||
func (s *Session) AddMessage(msg message.Message) {
|
||||
if msg.ID == "" {
|
||||
msg.ID = generateMessageID()
|
||||
}
|
||||
if msg.CreatedAt.IsZero() {
|
||||
msg.CreatedAt = time.Now()
|
||||
}
|
||||
if msg.UpdatedAt.IsZero() {
|
||||
msg.UpdatedAt = time.Now()
|
||||
}
|
||||
|
||||
s.Messages = append(s.Messages, msg)
|
||||
s.UpdatedAt = time.Now()
|
||||
}
|
||||
|
||||
// SetMetadata sets the session metadata.
|
||||
func (s *Session) SetMetadata(metadata Metadata) {
|
||||
s.Metadata = metadata
|
||||
s.UpdatedAt = time.Now()
|
||||
}
|
||||
|
||||
// sessionJSON is the on-disk format with parts serialized as JSON strings.
|
||||
type sessionJSON struct {
|
||||
Version string `json:"version"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Metadata Metadata `json:"metadata"`
|
||||
Messages []messageJSON `json:"messages"`
|
||||
}
|
||||
|
||||
type messageJSON struct {
|
||||
ID string `json:"id"`
|
||||
Role string `json:"role"`
|
||||
Parts json.RawMessage `json:"parts"` // type-tagged JSON array
|
||||
Model string `json:"model,omitempty"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// SaveToFile saves the session to a JSON file.
|
||||
func (s *Session) SaveToFile(filePath string) error {
|
||||
s.UpdatedAt = time.Now()
|
||||
|
||||
sj := sessionJSON{
|
||||
Version: s.Version,
|
||||
CreatedAt: s.CreatedAt,
|
||||
UpdatedAt: s.UpdatedAt,
|
||||
Metadata: s.Metadata,
|
||||
Messages: make([]messageJSON, len(s.Messages)),
|
||||
}
|
||||
|
||||
for i, msg := range s.Messages {
|
||||
parts, err := message.MarshalParts(msg.Parts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal parts for message %s: %w", msg.ID, err)
|
||||
}
|
||||
sj.Messages[i] = messageJSON{
|
||||
ID: msg.ID,
|
||||
Role: string(msg.Role),
|
||||
Parts: parts,
|
||||
Model: msg.Model,
|
||||
Provider: msg.Provider,
|
||||
CreatedAt: msg.CreatedAt,
|
||||
UpdatedAt: msg.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(sj, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal session: %v", err)
|
||||
}
|
||||
|
||||
return os.WriteFile(filePath, data, 0644)
|
||||
}
|
||||
|
||||
// LoadFromFile loads a session from a JSON file.
|
||||
func LoadFromFile(filePath string) (*Session, error) {
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read session file: %v", err)
|
||||
}
|
||||
|
||||
var sj sessionJSON
|
||||
if err := json.Unmarshal(data, &sj); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal session: %v", err)
|
||||
}
|
||||
|
||||
session := &Session{
|
||||
Version: sj.Version,
|
||||
CreatedAt: sj.CreatedAt,
|
||||
UpdatedAt: sj.UpdatedAt,
|
||||
Metadata: sj.Metadata,
|
||||
Messages: make([]message.Message, len(sj.Messages)),
|
||||
}
|
||||
|
||||
for i, mj := range sj.Messages {
|
||||
parts, err := message.UnmarshalParts(mj.Parts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal parts for message %s: %w", mj.ID, err)
|
||||
}
|
||||
session.Messages[i] = message.Message{
|
||||
ID: mj.ID,
|
||||
Role: message.MessageRole(mj.Role),
|
||||
Parts: parts,
|
||||
Model: mj.Model,
|
||||
Provider: mj.Provider,
|
||||
CreatedAt: mj.CreatedAt,
|
||||
UpdatedAt: mj.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// ConvertFromFantasyMessage converts a fantasy.Message to a message.Message.
|
||||
// This function bridges between the fantasy message format and the
|
||||
// session's internal message format for persistence.
|
||||
func ConvertFromFantasyMessage(msg fantasy.Message) message.Message {
|
||||
return message.FromFantasyMessage(msg)
|
||||
}
|
||||
|
||||
// generateMessageID generates a unique message ID.
|
||||
func generateMessageID() string {
|
||||
bytes := make([]byte, 8)
|
||||
_, _ = rand.Read(bytes)
|
||||
return "msg_" + hex.EncodeToString(bytes)
|
||||
}
|
||||
@@ -0,0 +1,714 @@
|
||||
# Plan 10: App-as-SDK-Consumer — Complete Integration
|
||||
|
||||
**Priority**: P4
|
||||
**Effort**: High
|
||||
**Goal**: Make the CLI app a full consumer of the SDK. `cmd/root.go` creates a `*Kit` via `kit.New()`. The app receives `*Kit`, calls `kit.PromptResult()`, subscribes to SDK events for TUI rendering, and extension observation events route through the SDK EventBus. This closes all deferred work from Plans 03, 05, and 09.
|
||||
|
||||
## Background
|
||||
|
||||
Plans 00–09 built the SDK surface (`pkg/kit/`) but the CLI app still bypasses it for the critical path:
|
||||
|
||||
- `cmd/root.go` calls `SetupAgent()` directly instead of `kit.New()`
|
||||
- `internal/app/app.go:executeStep()` calls `agent.GenerateWithLoopAndStreaming()` directly with 150+ lines of manual callback wiring, extension event dispatch, and session persistence — all of which the SDK already handles in `runTurn()`
|
||||
- Extension observation events (AgentStart, AgentEnd, MessageStart, MessageUpdate, MessageEnd) are emitted from `executeStep()`, not from the SDK
|
||||
- The app receives an `AgentRunner` interface, not a `*Kit`
|
||||
|
||||
After this plan, `executeStep()` becomes a thin wrapper around `kit.PromptResult()`, and extension events flow through the SDK's EventBus.
|
||||
|
||||
### Deferred Items Resolved
|
||||
|
||||
| Source | What | How |
|
||||
|--------|------|-----|
|
||||
| Plan 03 Step 6 | App TUI subscribes to SDK events | Step 5 |
|
||||
| Plan 03 Step 7 | Extension observation events forward to SDK EventBus | Step 4 |
|
||||
| Plan 05 Step 6 | `executeStep()` delegates to SDK `Prompt()` | Step 6 |
|
||||
| Plan 09 Phase 2 | App uses only SDK hooks, no direct runner calls | Step 6 |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Plans 00–09 (all complete)
|
||||
|
||||
## Step-by-Step
|
||||
|
||||
### Step 1: Extend Kit for CLI consumption
|
||||
|
||||
**Files**: `pkg/kit/kit.go`, `pkg/kit/setup.go`
|
||||
|
||||
The CLI needs fields that the programmatic SDK doesn't: spinner for Ollama loading, buffered debug logger, pre-loaded MCP config. Add these to `Options` and expose results via getters.
|
||||
|
||||
**1a. Add CLI fields to `Options`** (`pkg/kit/kit.go:48-71`):
|
||||
|
||||
```go
|
||||
type Options struct {
|
||||
// ... existing fields ...
|
||||
|
||||
// CLI-specific fields (ignored by programmatic SDK users)
|
||||
MCPConfig *config.Config // Pre-loaded MCP config (skips LoadAndValidateConfig if set)
|
||||
ShowSpinner bool // Show loading spinner for Ollama models
|
||||
SpinnerFunc agent.SpinnerFunc // Spinner implementation (nil = no spinner)
|
||||
UseBufferedLogger bool // Buffer debug messages for later display
|
||||
Debug bool // Enable debug logging
|
||||
}
|
||||
```
|
||||
|
||||
**1b. Add fields and getters to `Kit` struct** (`pkg/kit/kit.go:22-36`):
|
||||
|
||||
```go
|
||||
type Kit struct {
|
||||
// ... existing fields ...
|
||||
extRunner *extensions.Runner
|
||||
bufferedLogger *tools.BufferedDebugLogger
|
||||
}
|
||||
```
|
||||
|
||||
Getters:
|
||||
|
||||
```go
|
||||
// GetExtRunner returns the extension runner (nil if extensions are disabled).
|
||||
func (m *Kit) GetExtRunner() *extensions.Runner { return m.extRunner }
|
||||
|
||||
// GetBufferedLogger returns the buffered debug logger (nil if not configured).
|
||||
func (m *Kit) GetBufferedLogger() *tools.BufferedDebugLogger { return m.bufferedLogger }
|
||||
|
||||
// GetAgent returns the underlying agent. Callers that need the raw agent
|
||||
// (e.g. for GetTools(), GetLoadingMessage()) can use this.
|
||||
func (m *Kit) GetAgent() *agent.Agent { return m.agent }
|
||||
|
||||
// GetTreeSession returns the current tree session manager.
|
||||
// (Already exists as a method — verify it's public.)
|
||||
```
|
||||
|
||||
**1c. Update `New()`** (`pkg/kit/kit.go:111-204`):
|
||||
|
||||
- If `opts.MCPConfig != nil`, skip `config.LoadAndValidateConfig()` and use it directly
|
||||
- If `opts.Debug`, set `viper.Set("debug", true)`
|
||||
- Pass `ShowSpinner`, `SpinnerFunc`, `UseBufferedLogger` through to `SetupAgent()`
|
||||
- Store `agentResult.ExtRunner` and `agentResult.BufferedLogger` on the Kit struct
|
||||
|
||||
```go
|
||||
// In New(), replace lines 152-176:
|
||||
mcpConfig := opts.MCPConfig
|
||||
if mcpConfig == nil {
|
||||
var err error
|
||||
mcpConfig, err = config.LoadAndValidateConfig()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load MCP config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
agentResult, err := SetupAgent(ctx, AgentSetupOptions{
|
||||
MCPConfig: mcpConfig,
|
||||
Quiet: opts.Quiet,
|
||||
ShowSpinner: opts.ShowSpinner,
|
||||
SpinnerFunc: opts.SpinnerFunc,
|
||||
UseBufferedLogger: opts.UseBufferedLogger,
|
||||
CoreTools: opts.Tools,
|
||||
ExtraTools: opts.ExtraTools,
|
||||
ToolWrapper: hookToolWrapper(beforeToolCall, afterToolResult),
|
||||
})
|
||||
|
||||
// Store on Kit struct:
|
||||
k := &Kit{
|
||||
// ... existing fields ...
|
||||
extRunner: agentResult.ExtRunner,
|
||||
bufferedLogger: agentResult.BufferedLogger,
|
||||
}
|
||||
```
|
||||
|
||||
**Verification**:
|
||||
```bash
|
||||
go build -o output/kit ./cmd/kit
|
||||
go test -race ./...
|
||||
golangci-lint run ./...
|
||||
```
|
||||
|
||||
Existing behavior is unchanged — the new fields default to zero values.
|
||||
|
||||
---
|
||||
|
||||
### Step 2: Add TurnResult and PromptResult method
|
||||
|
||||
**File**: `pkg/kit/kit.go`
|
||||
|
||||
The current `Prompt()` returns `(string, error)`, which is fine for simple SDK usage but the app needs usage stats and conversation messages. Add a richer return path.
|
||||
|
||||
**2a. Define TurnResult** (new, in `pkg/kit/kit.go`):
|
||||
|
||||
```go
|
||||
// TurnResult contains the full result of a prompt turn, including usage
|
||||
// statistics and the updated conversation. Use PromptResult() instead of
|
||||
// Prompt() when you need access to this data.
|
||||
type TurnResult struct {
|
||||
// Response is the assistant's final text response.
|
||||
Response string
|
||||
|
||||
// TotalUsage is the aggregate token usage across all steps in the turn
|
||||
// (includes tool-calling loop iterations). Nil if the provider didn't
|
||||
// report usage.
|
||||
TotalUsage *FantasyUsage
|
||||
|
||||
// FinalUsage is the token usage from the last API call only. Use this
|
||||
// for context window fill estimation (InputTokens + OutputTokens ≈
|
||||
// current context size). Nil if unavailable.
|
||||
FinalUsage *FantasyUsage
|
||||
|
||||
// Messages is the full updated conversation after the turn, including
|
||||
// any tool call/result messages added during the agent loop.
|
||||
Messages []FantasyMessage
|
||||
}
|
||||
```
|
||||
|
||||
**2b. Modify `runTurn()` to return `*TurnResult`** (`pkg/kit/kit.go:319`):
|
||||
|
||||
Change signature from:
|
||||
```go
|
||||
func (m *Kit) runTurn(ctx context.Context, promptLabel string, prompt string, preMessages []fantasy.Message) (string, error)
|
||||
```
|
||||
To:
|
||||
```go
|
||||
func (m *Kit) runTurn(ctx context.Context, promptLabel string, prompt string, preMessages []fantasy.Message) (*TurnResult, error)
|
||||
```
|
||||
|
||||
Build and return `TurnResult` from the `agent.GenerateWithLoopResult`:
|
||||
|
||||
```go
|
||||
responseText := result.FinalResponse.Content.Text()
|
||||
|
||||
turnResult := &TurnResult{
|
||||
Response: responseText,
|
||||
Messages: result.ConversationMessages,
|
||||
}
|
||||
if result.TotalUsage != nil {
|
||||
turnResult.TotalUsage = result.TotalUsage
|
||||
}
|
||||
if result.FinalResponse != nil {
|
||||
turnResult.FinalUsage = &result.FinalResponse.Usage
|
||||
}
|
||||
|
||||
// ... existing event emission and persistence ...
|
||||
|
||||
return turnResult, nil
|
||||
```
|
||||
|
||||
On the error path, return `nil, err` (as before, but with `*TurnResult` instead of `""`).
|
||||
|
||||
**2c. Update all prompt methods** to extract the string from `TurnResult`:
|
||||
|
||||
```go
|
||||
func (m *Kit) Prompt(ctx context.Context, message string) (string, error) {
|
||||
result, err := m.runTurn(ctx, message, message, []fantasy.Message{
|
||||
fantasy.NewUserMessage(message),
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return result.Response, nil
|
||||
}
|
||||
```
|
||||
|
||||
Same pattern for `Steer()`, `FollowUp()`, `PromptWithOptions()`, `PromptWithCallbacks()`.
|
||||
|
||||
**2d. Add `PromptResult()` method**:
|
||||
|
||||
```go
|
||||
// PromptResult sends a message and returns the full turn result including
|
||||
// usage statistics and conversation messages. Use this instead of Prompt()
|
||||
// when you need more than just the response text.
|
||||
func (m *Kit) PromptResult(ctx context.Context, message string) (*TurnResult, error) {
|
||||
return m.runTurn(ctx, message, message, []fantasy.Message{
|
||||
fantasy.NewUserMessage(message),
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Verification**:
|
||||
```bash
|
||||
go build -o output/kit ./cmd/kit
|
||||
go test -race ./...
|
||||
golangci-lint run ./...
|
||||
```
|
||||
|
||||
Existing `Prompt()` callers (examples, tests) are unaffected.
|
||||
|
||||
---
|
||||
|
||||
### Step 3: Migrate cmd/root.go to use kit.New()
|
||||
|
||||
**Files**: `cmd/root.go`, `cmd/setup.go`
|
||||
|
||||
Replace the manual `SetupAgent()` → `InitTreeSession()` → `BuildAppOptions()` chain with a single `kit.New()` call.
|
||||
|
||||
**3a. Replace agent creation** in `runNormalMode()` (`cmd/root.go:336-362`):
|
||||
|
||||
Before:
|
||||
```go
|
||||
agentResult, err := SetupAgent(ctx, AgentSetupOptions{...})
|
||||
mcpAgent := agentResult.Agent
|
||||
defer func() { _ = mcpAgent.Close() }()
|
||||
provider, modelName, serverNames, toolNames := CollectAgentMetadata(mcpAgent, mcpConfig)
|
||||
```
|
||||
|
||||
After:
|
||||
```go
|
||||
// Build Kit options from CLI flags.
|
||||
kitOpts := &kit.Options{
|
||||
MCPConfig: mcpConfig,
|
||||
ShowSpinner: true,
|
||||
SpinnerFunc: spinnerFunc,
|
||||
UseBufferedLogger: true,
|
||||
Quiet: quietFlag,
|
||||
Debug: debugMode,
|
||||
NoSession: noSessionFlag,
|
||||
Continue: continueFlag,
|
||||
SessionPath: sessionPath,
|
||||
AutoCompact: autoCompactFlag,
|
||||
}
|
||||
if resumeFlag {
|
||||
sessions, _ := kit.ListSessions("")
|
||||
if len(sessions) > 0 {
|
||||
kitOpts.SessionPath = sessions[0].Path
|
||||
}
|
||||
}
|
||||
|
||||
kitInstance, err := kit.New(ctx, kitOpts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer kitInstance.Close()
|
||||
```
|
||||
|
||||
**3b. Extract metadata from Kit instead of raw agent**:
|
||||
|
||||
```go
|
||||
mcpAgent := kitInstance.GetAgent()
|
||||
provider, modelName, serverNames, toolNames := CollectAgentMetadata(mcpAgent, mcpConfig)
|
||||
```
|
||||
|
||||
**3c. Get buffered logger and tree session from Kit**:
|
||||
|
||||
```go
|
||||
bufferedLogger := kitInstance.GetBufferedLogger()
|
||||
// ... display buffered debug messages ...
|
||||
|
||||
treeSession := kitInstance.GetTreeSession()
|
||||
var messages []fantasy.Message
|
||||
if treeSession != nil {
|
||||
messages = treeSession.GetFantasyMessages()
|
||||
}
|
||||
```
|
||||
|
||||
**3d. Build app options using Kit**:
|
||||
|
||||
```go
|
||||
appOpts := BuildAppOptions(mcpAgent, mcpConfig, modelName, serverNames, toolNames, kitInstance.GetExtRunner())
|
||||
appOpts.TreeSession = treeSession
|
||||
appOpts.Kit = kitInstance // NEW — added in Step 5
|
||||
```
|
||||
|
||||
**3e. Extension context setup** — use Kit's extension runner:
|
||||
|
||||
```go
|
||||
extRunner := kitInstance.GetExtRunner()
|
||||
if extRunner != nil {
|
||||
extRunner.SetContext(extensions.Context{...})
|
||||
// Emit SessionStart
|
||||
}
|
||||
```
|
||||
|
||||
**3f. Remove the separate `kit.InitTreeSession()` call** — Kit.New() handles session creation.
|
||||
|
||||
**3g. Remove the `defer func() { _ = mcpAgent.Close() }()`** — `kitInstance.Close()` handles cleanup.
|
||||
|
||||
**Verification**:
|
||||
```bash
|
||||
go build -o output/kit ./cmd/kit
|
||||
go test -race ./...
|
||||
golangci-lint run ./...
|
||||
# Manual: run `kit -p "hello"` to verify non-interactive mode
|
||||
# Manual: run `kit` to verify interactive mode
|
||||
```
|
||||
|
||||
The app still uses its own `executeStep()` at this point — that migrates in Step 6.
|
||||
|
||||
---
|
||||
|
||||
### Step 4: Bridge extension observation events through SDK EventBus
|
||||
|
||||
**File**: `pkg/kit/extensions_bridge.go`
|
||||
|
||||
Currently `bridgeExtensions()` only bridges `Input` and `BeforeAgentStart` (hook events). The observation events (AgentStart, AgentEnd, MessageStart, MessageUpdate, MessageEnd) are emitted from `app.executeStep()` directly to the extension runner. After this step, the SDK emits them from `runTurn()`/`generate()` and the bridge forwards to extensions.
|
||||
|
||||
**4a. Subscribe to SDK events and forward to extension runner**:
|
||||
|
||||
Add to `bridgeExtensions()` (`pkg/kit/extensions_bridge.go:16`):
|
||||
|
||||
```go
|
||||
func (m *Kit) bridgeExtensions(runner *extensions.Runner) {
|
||||
// ... existing Input and BeforeAgentStart hooks ...
|
||||
|
||||
// Forward SDK observation events to extension runner.
|
||||
// These events are emitted by runTurn()/generate() and forwarded here
|
||||
// so extensions see them without the app having to emit them manually.
|
||||
|
||||
if runner.HasHandlers(extensions.AgentStart) {
|
||||
m.Subscribe(func(e Event) {
|
||||
if ev, ok := e.(TurnStartEvent); ok {
|
||||
runner.Emit(extensions.AgentStartEvent{Prompt: ev.Prompt})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if runner.HasHandlers(extensions.MessageStart) {
|
||||
m.Subscribe(func(e Event) {
|
||||
if _, ok := e.(MessageStartEvent); ok {
|
||||
runner.Emit(extensions.MessageStartEvent{})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if runner.HasHandlers(extensions.MessageUpdate) {
|
||||
m.Subscribe(func(e Event) {
|
||||
if ev, ok := e.(MessageUpdateEvent); ok {
|
||||
runner.Emit(extensions.MessageUpdateEvent{Chunk: ev.Chunk})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if runner.HasHandlers(extensions.MessageEnd) {
|
||||
m.Subscribe(func(e Event) {
|
||||
if ev, ok := e.(MessageEndEvent); ok {
|
||||
runner.Emit(extensions.MessageEndEvent{Content: ev.Content})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if runner.HasHandlers(extensions.AgentEnd) {
|
||||
m.Subscribe(func(e Event) {
|
||||
if ev, ok := e.(TurnEndEvent); ok {
|
||||
stopReason := "completed"
|
||||
response := ev.Response
|
||||
if ev.Error != nil {
|
||||
stopReason = "error"
|
||||
response = ""
|
||||
}
|
||||
runner.Emit(extensions.AgentEndEvent{
|
||||
Response: response,
|
||||
StopReason: stopReason,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**4b. Add SessionShutdown to Kit.Close()**:
|
||||
|
||||
In `pkg/kit/kit.go:Close()`:
|
||||
|
||||
```go
|
||||
func (m *Kit) Close() error {
|
||||
// Emit SessionShutdown for extensions.
|
||||
if m.extRunner != nil && m.extRunner.HasHandlers(extensions.SessionShutdown) {
|
||||
m.extRunner.Emit(extensions.SessionShutdownEvent{})
|
||||
}
|
||||
if m.treeSession != nil {
|
||||
_ = m.treeSession.Close()
|
||||
}
|
||||
return m.agent.Close()
|
||||
}
|
||||
```
|
||||
|
||||
**Verification**:
|
||||
```bash
|
||||
go build -o output/kit ./cmd/kit
|
||||
go test -race ./...
|
||||
golangci-lint run ./...
|
||||
```
|
||||
|
||||
At this point, extension observation events will fire from BOTH `executeStep()` (app) and the SDK bridge. This is intentional for the transition — Step 6 removes the app-side emission.
|
||||
|
||||
---
|
||||
|
||||
### Step 5: Wire app to Kit — add Kit field and SDK event → tea.Msg bridge
|
||||
|
||||
**Files**: `internal/app/options.go`, `internal/app/app.go`
|
||||
|
||||
Give the app a `*Kit` reference so it can call SDK prompt methods and subscribe to events.
|
||||
|
||||
**5a. Add Kit field to `app.Options`** (`internal/app/options.go:50`):
|
||||
|
||||
```go
|
||||
import kit "github.com/mark3labs/kit/pkg/kit"
|
||||
|
||||
type Options struct {
|
||||
// Kit is the SDK instance. When set, executeStep() delegates to
|
||||
// kit.PromptResult() and events flow through SDK subscriptions.
|
||||
Kit *kit.Kit
|
||||
|
||||
// Agent is the agent used to run the agentic loop. Required when Kit
|
||||
// is nil. When Kit is set, this field is ignored (Kit owns the agent).
|
||||
Agent AgentRunner
|
||||
|
||||
// ... rest unchanged ...
|
||||
}
|
||||
```
|
||||
|
||||
**5b. Create SDK event → tea.Msg bridge function** (`internal/app/app.go`):
|
||||
|
||||
```go
|
||||
// subscribeSDKEvents registers temporary SDK event subscribers that convert
|
||||
// SDK events to tea.Msg events and dispatch them via sendFn. Returns an
|
||||
// unsubscribe function that removes all listeners.
|
||||
func (a *App) subscribeSDKEvents(sendFn func(tea.Msg)) func() {
|
||||
k := a.opts.Kit
|
||||
var unsubs []func()
|
||||
|
||||
unsubs = append(unsubs, k.Subscribe(func(e kit.Event) {
|
||||
switch ev := e.(type) {
|
||||
case kit.ToolCallEvent:
|
||||
sendFn(ToolCallStartedEvent{ToolName: ev.ToolName, ToolArgs: ev.ToolArgs})
|
||||
case kit.ToolExecutionStartEvent:
|
||||
sendFn(ToolExecutionEvent{ToolName: ev.ToolName, IsStarting: true})
|
||||
case kit.ToolExecutionEndEvent:
|
||||
sendFn(ToolExecutionEvent{ToolName: ev.ToolName, IsStarting: false})
|
||||
case kit.ToolResultEvent:
|
||||
sendFn(ToolResultEvent{
|
||||
ToolName: ev.ToolName, ToolArgs: ev.ToolArgs,
|
||||
Result: ev.Result, IsError: ev.IsError,
|
||||
})
|
||||
case kit.ToolCallContentEvent:
|
||||
sendFn(ToolCallContentEvent{Content: ev.Content})
|
||||
case kit.ResponseEvent:
|
||||
sendFn(ResponseCompleteEvent{Content: ev.Content})
|
||||
case kit.MessageUpdateEvent:
|
||||
sendFn(StreamChunkEvent{Content: ev.Chunk})
|
||||
}
|
||||
}))
|
||||
|
||||
return func() {
|
||||
for _, unsub := range unsubs {
|
||||
unsub()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**5c. Pass Kit in `cmd/root.go`**:
|
||||
|
||||
In the `BuildAppOptions` call or directly after:
|
||||
|
||||
```go
|
||||
appOpts.Kit = kitInstance
|
||||
```
|
||||
|
||||
**Verification**:
|
||||
```bash
|
||||
go build -o output/kit ./cmd/kit
|
||||
go test -race ./...
|
||||
golangci-lint run ./...
|
||||
```
|
||||
|
||||
The bridge function exists but is not called yet. Step 6 wires it in.
|
||||
|
||||
---
|
||||
|
||||
### Step 6: Migrate executeStep() to use kit.PromptResult()
|
||||
|
||||
**File**: `internal/app/app.go`
|
||||
|
||||
Replace the 150+ line `executeStep()` with a thin wrapper around `kit.PromptResult()`.
|
||||
|
||||
**6a. Rewrite executeStep()**:
|
||||
|
||||
The new `executeStep()` when `opts.Kit` is set:
|
||||
|
||||
```go
|
||||
func (a *App) executeStep(ctx context.Context, prompt string, eventFn func(tea.Msg)) (*agent.GenerateWithLoopResult, error) {
|
||||
if a.opts.Kit == nil {
|
||||
return a.executeStepLegacy(ctx, prompt, eventFn)
|
||||
}
|
||||
|
||||
sendFn := func(msg tea.Msg) {
|
||||
if eventFn != nil {
|
||||
eventFn(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to SDK events for TUI rendering. The subscription is
|
||||
// temporary — it lives only for the duration of this step.
|
||||
unsub := a.subscribeSDKEvents(sendFn)
|
||||
defer unsub()
|
||||
|
||||
// Show spinner while the agent works.
|
||||
sendFn(SpinnerEvent{Show: true})
|
||||
|
||||
result, err := a.opts.Kit.PromptResult(ctx, prompt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Sync in-memory store with the SDK's authoritative conversation.
|
||||
a.store.Replace(result.Messages)
|
||||
|
||||
// Update usage tracker.
|
||||
a.updateUsageFromTurnResult(result, prompt)
|
||||
|
||||
return &agent.GenerateWithLoopResult{
|
||||
ConversationMessages: result.Messages,
|
||||
}, nil
|
||||
}
|
||||
```
|
||||
|
||||
**6b. Rename existing executeStep to executeStepLegacy**:
|
||||
|
||||
Keep the old implementation as `executeStepLegacy()` so the transition is safe. It remains as a fallback when `opts.Kit == nil` (e.g. in tests that supply a stub `AgentRunner`).
|
||||
|
||||
**6c. Add `updateUsageFromTurnResult` helper**:
|
||||
|
||||
```go
|
||||
func (a *App) updateUsageFromTurnResult(result *kit.TurnResult, userPrompt string) {
|
||||
if a.opts.UsageTracker == nil || result == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if result.TotalUsage != nil {
|
||||
inputTokens := int(result.TotalUsage.InputTokens)
|
||||
outputTokens := int(result.TotalUsage.OutputTokens)
|
||||
if inputTokens > 0 && outputTokens > 0 {
|
||||
cacheReadTokens := int(result.TotalUsage.CacheReadTokens)
|
||||
cacheWriteTokens := int(result.TotalUsage.CacheCreationTokens)
|
||||
a.opts.UsageTracker.UpdateUsage(inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens)
|
||||
} else {
|
||||
a.opts.UsageTracker.EstimateAndUpdateUsage(userPrompt, result.Response)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if result.FinalUsage != nil {
|
||||
if ct := int(result.FinalUsage.InputTokens) + int(result.FinalUsage.OutputTokens); ct > 0 {
|
||||
a.opts.UsageTracker.SetContextTokens(ct)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**6d. Remove extension event emission from `executeStepLegacy()`**:
|
||||
|
||||
Since the SDK bridge (Step 4) now forwards extension observation events, remove these direct calls from `executeStepLegacy()`:
|
||||
- `extensions.AgentStart` emission (line 432-434)
|
||||
- `extensions.MessageStart` emission (line 440-442)
|
||||
- `extensions.MessageUpdate` emission (line 473-475)
|
||||
- `extensions.MessageEnd` emission (line 496-498)
|
||||
- `extensions.AgentEnd` emission (lines 482-487, 501-506)
|
||||
|
||||
The `Input` and `BeforeAgentStart` extensions are already handled by the SDK hooks (bridged in Plan 09). Remove those too from `executeStepLegacy()`:
|
||||
- `extensions.Input` emission (lines 372-387)
|
||||
- `extensions.BeforeAgentStart` emission (lines 414-429)
|
||||
|
||||
What remains in `executeStepLegacy()` is just the core generation call — which is now essentially the same as calling `kit.PromptResult()`.
|
||||
|
||||
**6e. Remove SessionShutdown from `app.Close()`**:
|
||||
|
||||
Since `Kit.Close()` now handles SessionShutdown (Step 4b), remove:
|
||||
|
||||
```go
|
||||
// In app.Close() — remove:
|
||||
if a.opts.Extensions != nil && a.opts.Extensions.HasHandlers(extensions.SessionShutdown) {
|
||||
_, _ = a.opts.Extensions.Emit(extensions.SessionShutdownEvent{})
|
||||
}
|
||||
```
|
||||
|
||||
**Verification**:
|
||||
```bash
|
||||
go build -o output/kit ./cmd/kit
|
||||
go test -race ./...
|
||||
golangci-lint run ./...
|
||||
# Manual: run `kit -p "list files in the current directory"` — verify tool calls render
|
||||
# Manual: run `kit` in interactive mode — verify streaming, tool results, spinner
|
||||
# Manual: create a .kit/extensions/ extension with AgentStart handler — verify it fires
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 7: Clean up dead code
|
||||
|
||||
**Files**: `internal/app/app.go`, `internal/app/options.go`, `internal/app/events.go`, `cmd/setup.go`
|
||||
|
||||
**7a. Remove `executeStepLegacy()`**:
|
||||
|
||||
Once confident the SDK path works, delete `executeStepLegacy()` entirely. Update `executeStep()` to remove the `if a.opts.Kit == nil` guard.
|
||||
|
||||
**7b. Remove `AgentRunner` interface**:
|
||||
|
||||
`internal/app/options.go:17-28` — delete `AgentRunner`. The `Agent AgentRunner` field is no longer used when `Kit` is set. Remove the `Agent` field from `Options`.
|
||||
|
||||
**7c. Remove `Extensions` field from `app.Options`**:
|
||||
|
||||
`internal/app/options.go:94-98` — the app no longer calls `a.opts.Extensions.Emit()` directly. Extension dispatch goes through SDK hooks/events. Remove the field and all `a.opts.Extensions` references in `app.go`.
|
||||
|
||||
**7d. Simplify `BuildAppOptions()` in `cmd/setup.go`**:
|
||||
|
||||
Remove the `mcpAgent` and `extRunner` parameters since the app gets these from `Kit`:
|
||||
|
||||
```go
|
||||
func BuildAppOptions(kitInstance *kit.Kit, mcpConfig *config.Config,
|
||||
modelName string, serverNames, toolNames []string) app.Options {
|
||||
return app.Options{
|
||||
Kit: kitInstance,
|
||||
MCPConfig: mcpConfig,
|
||||
ModelName: modelName,
|
||||
ServerNames: serverNames,
|
||||
ToolNames: toolNames,
|
||||
StreamingEnabled: viper.GetBool("stream"),
|
||||
Quiet: quietFlag,
|
||||
Debug: viper.GetBool("debug"),
|
||||
CompactMode: viper.GetBool("compact"),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**7e. Remove `updateUsage()` from `app.go`** (`app.go:596-627`):
|
||||
|
||||
Replaced by `updateUsageFromTurnResult()` which works with `TurnResult` instead of raw `GenerateWithLoopResult`.
|
||||
|
||||
**7f. Simplify `SessionStart` emission**:
|
||||
|
||||
Move SessionStart from `cmd/root.go:448` into `Kit.New()` or a new `Kit.EmitSessionStart()` method called by the CLI after extension context is configured.
|
||||
|
||||
**7g. Remove `inputSource()` helper** (`app.go:524-532`):
|
||||
|
||||
Only used by the now-removed Input extension emission.
|
||||
|
||||
**7h. Run final verification**:
|
||||
|
||||
```bash
|
||||
go build -o output/kit ./cmd/kit
|
||||
go test -race ./...
|
||||
golangci-lint run ./...
|
||||
```
|
||||
|
||||
Confirm no references to removed types/functions. Confirm no unused imports.
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
- [ ] `go build -o output/kit ./cmd/kit` succeeds
|
||||
- [ ] `go test -race ./...` passes
|
||||
- [ ] `golangci-lint run ./...` — 0 issues
|
||||
- [ ] `kit.New()` creates agent, session, extensions in one call
|
||||
- [ ] `cmd/root.go` no longer calls `SetupAgent()` directly
|
||||
- [ ] `executeStep()` delegates to `kit.PromptResult()`
|
||||
- [ ] SDK events drive TUI rendering (tool calls, streaming, results)
|
||||
- [ ] Extension observation events (AgentStart/End, MessageStart/Update/End) fire via SDK bridge
|
||||
- [ ] Extension interception events (Input, BeforeAgentStart, ToolCall, ToolResult) still work
|
||||
- [ ] Usage tracker receives correct token counts
|
||||
- [ ] Session persistence works (tree session)
|
||||
- [ ] `--continue` / `--no-session` / `--session` flags work
|
||||
- [ ] Spinner shows/hides correctly
|
||||
- [ ] Interactive mode (BubbleTea) works
|
||||
- [ ] Non-interactive mode (`-p "..."`) works
|
||||
- [ ] Extension SessionShutdown fires on close
|
||||
- [ ] No remaining direct `extensions.Emit()` calls in `app.go`
|
||||
- [ ] `AgentRunner` interface removed
|
||||
- [ ] `app.Options.Extensions` field removed
|
||||
+5
-1
@@ -66,6 +66,7 @@ internal/app/ TUI/interactive mode — subscribes to SDK events
|
||||
| **07** | P2 | Compaction APIs | 00, 03, 04 |
|
||||
| **08** | P2 | Skills & prompts system | 00, 02 |
|
||||
| **09** | P3 | Extension hook system | 00, 01, 02, 03 |
|
||||
| **10** | P4 | App-as-SDK-consumer — complete integration | 00–09 |
|
||||
|
||||
### Recommended Batches
|
||||
|
||||
@@ -81,6 +82,9 @@ Auth, compaction, skills. CLI commands use SDK functions.
|
||||
**Batch 4 — Extensibility** (Plan 09):
|
||||
Hook system with extension bridge. App's extension dispatch routes through SDK hooks.
|
||||
|
||||
**Batch 5 — Full Integration** (Plan 10):
|
||||
CLI uses `kit.New()`, app calls `kit.PromptResult()`, extension events route through SDK EventBus. Closes all deferred items from Plans 03, 05, 09. Removes `AgentRunner` interface, `app.Options.Extensions`, and legacy `executeStep` code.
|
||||
|
||||
## Parity with Pi SDK
|
||||
|
||||
After all plans:
|
||||
@@ -97,4 +101,4 @@ After all plans:
|
||||
| Compaction APIs | Yes | Plan 07 |
|
||||
| Skills/prompts system | Yes | Plan 08 |
|
||||
| Extension hooks (20+ events) | Yes | Plan 09 |
|
||||
| App built on SDK | Yes | Gradual across all plans |
|
||||
| App built on SDK | Yes | Plan 10 (completes deferred work from 03, 05, 09) |
|
||||
|
||||
Reference in New Issue
Block a user