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
9.0 KiB
Plan 05: Additional Prompt Modes
Priority: P1
Effort: Medium
Goal: Add Steer(), FollowUp(), PromptWithOptions() methods; app's executeStep() should call SDK methods
Background
Pi has session.prompt(), session.steer(), session.followUp(), session.compact(). Kit only has Prompt() and PromptWithCallbacks(). The Kit CLI app implements its own agent loop in internal/app/app.go:executeStep() which duplicates SDK logic. After this plan, both the app and SDK users call the same methods.
Prerequisites
- Plan 00 (Create
pkg/kit/) - Plan 03 (Event subscriber system)
Step-by-Step
Step 1: Extract shared callback helpers
To avoid duplicating callback wiring across Prompt, Steer, FollowUp, etc., extract internal helpers:
File: pkg/kit/kit.go
func (m *Kit) makeToolCallHandler() agent.ToolCallHandler {
return func(name, args string) {
m.events.emit(ToolCallEvent{ToolName: name, ToolArgs: args})
}
}
func (m *Kit) makeToolExecutionHandler() agent.ToolExecutionHandler {
return func(name string, isStarting bool) {
if isStarting {
m.events.emit(ToolExecutionStartEvent{ToolName: name})
} else {
m.events.emit(ToolExecutionEndEvent{ToolName: name})
}
}
}
func (m *Kit) makeToolResultHandler() agent.ToolResultHandler {
return func(name, args, result string, isError bool) {
m.events.emit(ToolResultEvent{ToolName: name, ToolArgs: args, Result: result, IsError: isError})
}
}
func (m *Kit) makeResponseHandler() agent.ResponseHandler {
return func(content string) { m.events.emit(ResponseEvent{Content: content}) }
}
func (m *Kit) makeStreamingHandler() agent.StreamingResponseHandler {
return func(chunk string) { m.events.emit(MessageUpdateEvent{Chunk: chunk}) }
}
// getMessages retrieves conversation history from the best available source.
func (m *Kit) getMessages() []fantasy.Message {
if m.treeSession != nil {
msgs, _, _ := m.treeSession.BuildContext()
return msgs
}
return m.sessionMgr.GetMessages()
}
// updateSession persists generation results.
func (m *Kit) updateSession(userMsg fantasy.Message, result *agent.GenerateWithLoopResult) {
if m.treeSession != nil {
m.treeSession.AppendFantasyMessage(userMsg)
for _, msg := range result.Messages {
m.treeSession.AppendMessage(msg)
}
}
_ = m.sessionMgr.ReplaceAllMessages(result.ConversationMessages)
}
// generate is the shared generation path for all prompt modes.
func (m *Kit) generate(ctx context.Context, messages []fantasy.Message) (*agent.GenerateWithLoopResult, error) {
return m.agent.GenerateWithLoopAndStreaming(
ctx, messages,
m.makeToolCallHandler(),
m.makeToolExecutionHandler(),
m.makeToolResultHandler(),
m.makeResponseHandler(),
nil, // onToolCallContent
m.makeStreamingHandler(),
)
}
Step 2: Refactor Prompt() to use shared helpers
func (m *Kit) Prompt(ctx context.Context, msg string) (string, error) {
messages := m.getMessages()
userMsg := fantasy.NewUserMessage(msg)
messages = append(messages, userMsg)
m.events.emit(TurnStartEvent{Prompt: msg})
m.events.emit(MessageStartEvent{})
result, err := m.generate(ctx, messages)
if err != nil {
m.events.emit(TurnEndEvent{Error: err})
return "", fmt.Errorf("generation failed: %w", err)
}
m.updateSession(userMsg, result)
response := result.FinalResponse.Content.Text()
m.events.emit(MessageEndEvent{Content: response})
m.events.emit(TurnEndEvent{Response: response})
return response, nil
}
Step 3: Add Steer()
// Steer injects a system message and triggers a new agent turn.
// Use for dynamically adjusting behavior without a visible user message.
func (m *Kit) Steer(ctx context.Context, instruction string) (string, error) {
messages := m.getMessages()
sysMsg := fantasy.NewSystemMessage(instruction)
messages = append(messages, sysMsg)
userMsg := fantasy.NewUserMessage("Please acknowledge and follow the above instruction.")
messages = append(messages, userMsg)
m.events.emit(TurnStartEvent{Prompt: "[steer] " + instruction})
m.events.emit(MessageStartEvent{})
result, err := m.generate(ctx, messages)
if err != nil {
m.events.emit(TurnEndEvent{Error: err})
return "", fmt.Errorf("steer failed: %w", err)
}
m.updateSession(userMsg, result)
response := result.FinalResponse.Content.Text()
m.events.emit(MessageEndEvent{Content: response})
m.events.emit(TurnEndEvent{Response: response})
return response, nil
}
Step 4: Add FollowUp()
// FollowUp continues the conversation without new user input.
func (m *Kit) FollowUp(ctx context.Context) (string, error) {
messages := m.getMessages()
if len(messages) == 0 {
return "", fmt.Errorf("cannot follow up: no previous messages")
}
userMsg := fantasy.NewUserMessage("Continue.")
messages = append(messages, userMsg)
m.events.emit(TurnStartEvent{Prompt: "[follow-up]"})
m.events.emit(MessageStartEvent{})
result, err := m.generate(ctx, messages)
if err != nil {
m.events.emit(TurnEndEvent{Error: err})
return "", fmt.Errorf("follow-up failed: %w", err)
}
m.updateSession(userMsg, result)
response := result.FinalResponse.Content.Text()
m.events.emit(MessageEndEvent{Content: response})
m.events.emit(TurnEndEvent{Response: response})
return response, nil
}
Step 5: Add PromptWithOptions()
type PromptOptions struct {
SystemMessage string // Injected before the prompt
MaxSteps int // Override max steps for this call (0 = default)
}
func (m *Kit) PromptWithOptions(ctx context.Context, msg string, opts PromptOptions) (string, error) {
messages := m.getMessages()
if opts.SystemMessage != "" {
messages = append(messages, fantasy.NewSystemMessage(opts.SystemMessage))
}
userMsg := fantasy.NewUserMessage(msg)
messages = append(messages, userMsg)
m.events.emit(TurnStartEvent{Prompt: msg})
m.events.emit(MessageStartEvent{})
result, err := m.generate(ctx, messages)
if err != nil {
m.events.emit(TurnEndEvent{Error: err})
return "", fmt.Errorf("generation failed: %w", err)
}
m.updateSession(userMsg, result)
response := result.FinalResponse.Content.Text()
m.events.emit(MessageEndEvent{Content: response})
m.events.emit(TurnEndEvent{Response: response})
return response, nil
}
Step 6: App-as-Consumer — Refactor executeStep() to use SDK
Currently internal/app/app.go:executeStep() (lines 364-520) contains a full agent loop with extension events, message building, and session persistence. It should be replaced by SDK method calls.
File: internal/app/app.go (migration)
// Before: 150+ lines of agent loop logic in executeStep()
// After: executeStep delegates to the Kit SDK
func (a *App) executeStep(ctx context.Context, prompt string, sendFn func(tea.Msg)) (*agent.GenerateWithLoopResult, error) {
// Extension Input hook (stays in app — it's a pre-SDK concern)
if a.opts.Extensions != nil && a.opts.Extensions.HasHandlers(extensions.Input) {
result, _ := a.opts.Extensions.Emit(extensions.InputEvent{Text: prompt})
if r, ok := result.(extensions.InputResult); ok && r.Action == "handled" {
return nil, nil
}
if r, ok := result.(extensions.InputResult); ok && r.Text != "" {
prompt = r.Text
}
}
sendFn(SpinnerEvent{Show: true})
// Use SDK prompt — events handled by subscriber bridge (Plan 03)
response, err := a.kit.Prompt(ctx, prompt)
if err != nil {
sendFn(StepErrorEvent{Err: err})
return nil, err
}
sendFn(SpinnerEvent{Show: false})
sendFn(StepCompleteEvent{})
_ = response
return nil, nil // Result comes through events
}
Note: This is a simplification. The real migration needs to handle:
- Extension
BeforeAgentStartevents (map to Plan 09 hooks) - Spinner show/hide
- The fact that
executeStepreturns*GenerateWithLoopResultfor further processing
The migration is gradual:
- Phase 1: App calls
kit.Prompt()for simple cases - Phase 2: Extension events bridge through SDK hooks (Plan 09)
- Phase 3:
executeStep()becomes a thin adapter
Step 7: Verify
go build -o output/kit ./cmd/kit
go test -race ./...
go vet ./...
Files Changed Summary
| Action | File | Change |
|---|---|---|
| EDIT | pkg/kit/kit.go |
Steer(), FollowUp(), PromptWithOptions(), shared helpers |
| EDIT | internal/app/app.go |
Gradual migration of executeStep to use SDK |
Verification Checklist
Steer()injects system message and triggers responseFollowUp()continues without user messagePromptWithOptions()accepts per-call system message- All methods emit events via EventBus
- Shared helpers eliminate callback duplication
- App's
executeStep()uses SDK (at least for simple paths)