feat: add tool mgmt, model mgmt, options, event bus, LLM completion, steer mode, and 10 example extensions

Phase 2+3 extension API additions:
- Tool management: GetAllTools, SetActiveTools (plan-mode support)
- Model management: SetModel, GetAvailableModels, ModelChangedEvent
- Extension options: RegisterOption, GetOption, SetOption (env/config/default)
- Inter-extension event bus: OnCustomEvent, EmitCustomEvent
- Direct LLM completion: ctx.Complete with streaming/blocking modes
- Steer delivery mode: CancelAndSend for interrupt-and-redirect

New example extensions (10):
- plan-mode.go: read-only exploration with /plan toggle
- summarize.go: conversation summarization via ctx.Complete
- bookmark.go: persistent bookmarks via AppendEntry/GetEntries
- auto-commit.go: auto-commit on exit using last assistant message
- permission-gate.go: confirm dangerous bash commands
- protected-paths.go: block writes to .env, .git/, secrets/
- notify.go: desktop notifications on agent completion
- inline-bash.go: !{cmd} expansion in prompts
- pirate.go: system prompt persona injection
- project-rules.go: load .kit/rules/*.md into system prompt

Always-wrap tools through runner for SetActiveTools disabled-tool checking.
Removed phase1/phase2 test extensions from examples.
This commit is contained in:
Ed Zynda
2026-03-02 14:31:35 +03:00
parent 9449f1fcdf
commit 23c16bb197
22 changed files with 1552 additions and 29 deletions
+41 -8
View File
@@ -14,6 +14,7 @@ import (
"github.com/mark3labs/kit/internal/app"
"github.com/mark3labs/kit/internal/config"
"github.com/mark3labs/kit/internal/extensions"
"github.com/mark3labs/kit/internal/models"
"github.com/mark3labs/kit/internal/ui"
kit "github.com/mark3labs/kit/pkg/kit"
"github.com/spf13/cobra"
@@ -606,14 +607,15 @@ func runNormalMode(ctx context.Context) error {
if kitInstance.HasExtensions() {
cwd, _ := os.Getwd()
kitInstance.SetExtensionContext(extensions.Context{
CWD: cwd,
Model: modelName,
Interactive: promptFlag == "",
Print: func(text string) { appInstance.PrintFromExtension("", text) },
PrintInfo: func(text string) { appInstance.PrintFromExtension("info", text) },
PrintError: func(text string) { appInstance.PrintFromExtension("error", text) },
PrintBlock: appInstance.PrintBlockFromExtension,
SendMessage: func(text string) { appInstance.Run(text) },
CWD: cwd,
Model: modelName,
Interactive: promptFlag == "",
Print: func(text string) { appInstance.PrintFromExtension("", text) },
PrintInfo: func(text string) { appInstance.PrintFromExtension("info", text) },
PrintError: func(text string) { appInstance.PrintFromExtension("error", text) },
PrintBlock: appInstance.PrintBlockFromExtension,
SendMessage: func(text string) { appInstance.Run(text) },
CancelAndSend: func(text string) { appInstance.Steer(text) },
SetWidget: func(config extensions.WidgetConfig) {
kitInstance.SetExtensionWidget(config)
appInstance.NotifyWidgetUpdate()
@@ -737,6 +739,37 @@ func runNormalMode(ctx context.Context) error {
kitInstance.RemoveExtensionStatus(key)
appInstance.NotifyWidgetUpdate()
},
GetOption: func(name string) string {
return kitInstance.GetExtensionOption(name)
},
SetOption: func(name string, value string) {
kitInstance.SetExtensionOption(name, value)
},
SetModel: func(modelString string) error {
err := kitInstance.SetModel(context.Background(), modelString)
if err != nil {
return err
}
// Notify TUI so it updates model in status bar.
p, m, _ := models.ParseModelString(modelString)
appInstance.NotifyModelChanged(p, m)
return nil
},
GetAvailableModels: func() []extensions.ModelInfoEntry {
return kitInstance.GetAvailableModels()
},
EmitCustomEvent: func(name string, data string) {
kitInstance.EmitExtensionCustomEvent(name, data)
},
Complete: func(req extensions.CompleteRequest) (extensions.CompleteResponse, error) {
return kitInstance.ExecuteCompletion(context.Background(), req)
},
GetAllTools: func() []extensions.ToolInfo {
return kitInstance.GetExtensionToolInfos()
},
SetActiveTools: func(names []string) {
kitInstance.SetExtensionActiveTools(names)
},
ShowOverlay: func(config extensions.OverlayConfig) extensions.OverlayResult {
ch := make(chan app.OverlayResponse, 1)
appInstance.SendOverlayRequest(app.OverlayRequestEvent{
+72
View File
@@ -0,0 +1,72 @@
//go:build ignore
package main
import (
"os/exec"
"strings"
"kit/ext"
)
// Init automatically commits staged changes when the session shuts down,
// using the last assistant message as the commit message. Inspired by
// Pi's auto-commit-on-exit.ts.
//
// Only commits if:
// - There are staged changes (git diff --cached is non-empty)
// - There is at least one assistant message to use as commit message
//
// The commit message is derived from the last assistant response, trimmed
// to the first paragraph (max 72 chars for the subject line).
//
// Usage: kit -e examples/extensions/auto-commit.go
func Init(api ext.API) {
api.OnSessionShutdown(func(_ ext.SessionShutdownEvent, ctx ext.Context) {
// Check for staged changes.
diff, err := exec.Command("git", "diff", "--cached", "--quiet").CombinedOutput()
_ = diff
if err == nil {
return // exit code 0 means no staged changes
}
// Get the last assistant message.
msgs := ctx.GetMessages()
var lastAssistant string
for i := len(msgs) - 1; i >= 0; i-- {
if msgs[i].Role == "assistant" {
lastAssistant = msgs[i].Content
break
}
}
if lastAssistant == "" {
return
}
// Build commit message: first paragraph, subject line max 72 chars.
subject := firstParagraph(lastAssistant)
if len(subject) > 72 {
subject = subject[:69] + "..."
}
// Commit.
cmd := exec.Command("git", "commit", "-m", subject)
output, err := cmd.CombinedOutput()
if err != nil {
ctx.PrintError("Auto-commit failed: " + string(output))
return
}
ctx.PrintInfo("Auto-committed: " + subject)
})
}
// firstParagraph returns the first non-empty paragraph of text.
func firstParagraph(text string) string {
text = strings.TrimSpace(text)
// Split on double newlines (paragraph breaks).
parts := strings.SplitN(text, "\n\n", 2)
line := strings.TrimSpace(parts[0])
// Collapse to single line.
line = strings.ReplaceAll(line, "\n", " ")
return line
}
+79
View File
@@ -0,0 +1,79 @@
//go:build ignore
package main
import (
"encoding/json"
"fmt"
"strings"
"time"
"kit/ext"
)
// Init adds bookmark commands for marking and recalling important points in
// a conversation. Bookmarks are persisted in the session tree and survive
// restarts. Inspired by Pi's bookmark.ts.
//
// Commands:
//
// /bookmark <label> — bookmark the current point with a label
// /bookmarks — list all bookmarks in this session
//
// Usage: kit -e examples/extensions/bookmark.go
func Init(api ext.API) {
api.RegisterCommand(ext.CommandDef{
Name: "bookmark",
Description: "Bookmark the current point in the conversation",
Execute: func(args string, ctx ext.Context) (string, error) {
label := strings.TrimSpace(args)
if label == "" {
label = time.Now().Format("15:04:05")
}
// Count existing messages to record position.
msgs := ctx.GetMessages()
data, _ := json.Marshal(map[string]any{
"label": label,
"messages": len(msgs),
})
_, err := ctx.AppendEntry("bookmark", string(data))
if err != nil {
ctx.PrintError("Failed to save bookmark: " + err.Error())
return "", nil
}
ctx.PrintInfo(fmt.Sprintf("Bookmarked: %s (at message %d)", label, len(msgs)))
return "", nil
},
})
api.RegisterCommand(ext.CommandDef{
Name: "bookmarks",
Description: "List all bookmarks in this session",
Execute: func(args string, ctx ext.Context) (string, error) {
entries := ctx.GetEntries("bookmark")
if len(entries) == 0 {
ctx.PrintInfo("No bookmarks yet. Use /bookmark <label> to create one.")
return "", nil
}
var lines []string
for i, e := range entries {
var data map[string]any
if err := json.Unmarshal([]byte(e.Data), &data); err != nil {
continue
}
label, _ := data["label"].(string)
msgCount, _ := data["messages"].(float64)
lines = append(lines, fmt.Sprintf(" %d. %s (msg %d, %s)",
i+1, label, int(msgCount), e.Timestamp[:19]))
}
ctx.PrintInfo("Bookmarks:\n" + strings.Join(lines, "\n"))
return "", nil
},
})
}
+52
View File
@@ -0,0 +1,52 @@
//go:build ignore
package main
import (
"os/exec"
"regexp"
"strings"
"kit/ext"
)
// Init expands inline bash expressions in user prompts before they reach the
// LLM. Text like !{git branch --show-current} is replaced with the command's
// stdout. Inspired by Pi's inline-bash.ts.
//
// Examples:
//
// "Fix the tests on !{git branch --show-current}"
// → "Fix the tests on main"
//
// "The current directory is !{pwd}"
// → "The current directory is /home/user/project"
//
// Usage: kit -e examples/extensions/inline-bash.go
func Init(api ext.API) {
// Matches !{...} with non-greedy content.
re := regexp.MustCompile(`!\{([^}]+)\}`)
api.OnInput(func(ev ext.InputEvent, ctx ext.Context) *ext.InputResult {
if !re.MatchString(ev.Text) {
return nil
}
expanded := re.ReplaceAllStringFunc(ev.Text, func(match string) string {
// Extract the command between !{ and }.
cmd := re.FindStringSubmatch(match)[1]
cmd = strings.TrimSpace(cmd)
out, err := exec.Command("bash", "-c", cmd).Output()
if err != nil {
return match // keep original on error
}
return strings.TrimSpace(string(out))
})
return &ext.InputResult{
Action: "transform",
Text: expanded,
}
})
}
+35
View File
@@ -0,0 +1,35 @@
//go:build ignore
package main
import (
"os/exec"
"runtime"
"kit/ext"
)
// Init sends a desktop notification when the agent finishes responding.
// Useful for long-running tasks — get notified without watching the terminal.
// Inspired by Pi's notify.ts.
//
// Supports: Linux (notify-send), macOS (osascript).
//
// Usage: kit -e examples/extensions/notify.go
func Init(api ext.API) {
api.OnAgentEnd(func(_ ext.AgentEndEvent, ctx ext.Context) {
sendNotification("Kit", "Agent finished responding")
})
}
func sendNotification(title, body string) {
switch runtime.GOOS {
case "linux":
// Uses notify-send (libnotify) — available on most Linux desktops.
_ = exec.Command("notify-send", "-a", "Kit", title, body).Start()
case "darwin":
// Uses macOS built-in osascript for native notifications.
script := `display notification "` + body + `" with title "` + title + `"`
_ = exec.Command("osascript", "-e", script).Start()
}
}
+64
View File
@@ -0,0 +1,64 @@
//go:build ignore
package main
import (
"encoding/json"
"strings"
"kit/ext"
)
// Init intercepts potentially dangerous bash commands and asks the user for
// confirmation before allowing execution. Inspired by Pi's permission-gate.ts.
//
// Dangerous patterns: rm -rf, sudo, chmod 777, mkfs, dd, > /dev/
//
// Usage: kit -e examples/extensions/permission-gate.go
func Init(api ext.API) {
// Patterns that require user confirmation.
dangerousPatterns := []string{
"rm -rf",
"rm -r /",
"sudo ",
"chmod 777",
"chmod -R 777",
"mkfs",
"dd if=",
"> /dev/",
":(){ :|:& };:",
}
api.OnToolCall(func(tc ext.ToolCallEvent, ctx ext.Context) *ext.ToolCallResult {
if tc.ToolName != "Bash" {
return nil
}
// Extract the command from the tool input JSON.
var input struct {
Command string `json:"command"`
}
if err := json.Unmarshal([]byte(tc.Input), &input); err != nil {
return nil
}
cmd := strings.ToLower(input.Command)
// Check for dangerous patterns.
for _, pattern := range dangerousPatterns {
if strings.Contains(cmd, strings.ToLower(pattern)) {
result := ctx.PromptConfirm(ext.PromptConfirmConfig{
Message: "Dangerous command detected: " + input.Command + "\n\nAllow execution?",
})
if result.Cancelled || !result.Value {
return &ext.ToolCallResult{
Block: true,
Reason: "User denied execution of dangerous command: " + input.Command,
}
}
return nil // user approved
}
}
return nil
})
}
+28
View File
@@ -0,0 +1,28 @@
//go:build ignore
package main
import "kit/ext"
// Init injects a pirate persona into the system prompt, causing the LLM to
// respond in pirate-speak. Demonstrates OnBeforeAgentStart system prompt
// injection. Inspired by Pi's pirate.ts.
//
// Usage: kit -e examples/extensions/pirate.go
func Init(api ext.API) {
piratePrompt := `
You are a pirate! You must:
- Start every response with "Ahoy!"
- Use pirate slang (ye, matey, arr, landlubber, etc.)
- Refer to files as "scrolls" and directories as "treasure chests"
- Call errors "cursed mishaps" and bugs "sea monsters"
- End responses with a pirate saying
Despite the pirate persona, your technical advice must remain accurate and helpful.`
api.OnBeforeAgentStart(func(_ ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
return &ext.BeforeAgentStartResult{
SystemPrompt: &piratePrompt,
}
})
}
+88
View File
@@ -0,0 +1,88 @@
//go:build ignore
package main
import (
"strings"
"kit/ext"
)
// Init implements a plan/explore mode that restricts the agent to read-only
// tools. Toggle with /plan (or start in plan mode via KIT_OPT_PLAN=true).
// Inspired by Pi's plan-mode extension.
//
// In plan mode the agent can only use read, grep, find, and ls — it cannot
// write files, run bash, or make edits. This is useful for exploring a
// codebase, reviewing architecture, or generating plans before executing.
//
// The status bar shows the current mode and the system prompt is augmented
// with planning instructions when active.
//
// Usage: kit -e examples/extensions/plan-mode.go
//
// Start in plan mode: KIT_OPT_PLAN=true kit -e examples/extensions/plan-mode.go
func Init(api ext.API) {
// Read-only tool set (matches core.ReadOnlyTools).
readOnlyTools := []string{"read", "grep", "find", "ls"}
var planActive bool
// Register "plan" option so users can start in plan mode via env/config.
api.RegisterOption(ext.OptionDef{
Name: "plan",
Description: "Start in plan mode (read-only tools)",
Default: "false",
})
// /plan — toggle plan mode on or off.
api.RegisterCommand(ext.CommandDef{
Name: "plan",
Description: "Toggle plan/explore mode (read-only tools)",
Execute: func(args string, ctx ext.Context) (string, error) {
planActive = !planActive
applyMode(ctx, planActive, readOnlyTools)
return "", nil
},
})
// Check option at session start to enable plan mode from env/config.
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
opt := strings.ToLower(ctx.GetOption("plan"))
if opt == "true" || opt == "1" || opt == "yes" {
planActive = true
applyMode(ctx, true, readOnlyTools)
}
})
// Inject planning instructions into the system prompt when active.
api.OnBeforeAgentStart(func(_ ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
if !planActive {
return nil
}
prompt := `You are in PLAN MODE (read-only exploration).
You can ONLY read, search, and explore the codebase. You CANNOT write files,
run commands, or make edits. Focus on:
- Understanding the codebase structure and architecture
- Identifying relevant files and patterns
- Generating detailed plans and recommendations
- Answering questions about how the code works
When the user is ready to execute, they will exit plan mode with /plan.`
return &ext.BeforeAgentStartResult{
SystemPrompt: &prompt,
}
})
}
func applyMode(ctx ext.Context, active bool, readOnlyTools []string) {
if active {
ctx.SetActiveTools(readOnlyTools)
ctx.SetStatus("plan-mode", "PLAN MODE (read-only)", 10)
ctx.PrintInfo("Plan mode ON — agent restricted to read-only tools (read, grep, find, ls).\nUse /plan to toggle off.")
} else {
ctx.SetActiveTools(nil) // re-enable all tools
ctx.RemoveStatus("plan-mode")
ctx.PrintInfo("Plan mode OFF — all tools re-enabled.")
}
}
+71
View File
@@ -0,0 +1,71 @@
//go:build ignore
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"kit/ext"
)
// Init loads project-specific rules from .kit/rules/ into the system prompt.
// Each .md file in the rules directory is injected as additional context,
// giving projects a way to customise LLM behaviour without editing the
// main system prompt. Inspired by Pi's claude-rules.ts.
//
// Place rule files in:
//
// .kit/rules/code-style.md
// .kit/rules/testing.md
// .kit/rules/security.md
//
// Usage: kit -e examples/extensions/project-rules.go
func Init(api ext.API) {
var rules string
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
rulesDir := filepath.Join(ctx.CWD, ".kit", "rules")
entries, err := os.ReadDir(rulesDir)
if err != nil {
return // no rules directory, nothing to do
}
var parts []string
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !strings.HasSuffix(name, ".md") && !strings.HasSuffix(name, ".txt") {
continue
}
data, err := os.ReadFile(filepath.Join(rulesDir, name))
if err != nil {
continue
}
content := strings.TrimSpace(string(data))
if content != "" {
parts = append(parts, "## "+strings.TrimSuffix(name, filepath.Ext(name))+"\n\n"+content)
}
}
if len(parts) == 0 {
return
}
rules = "# Project Rules\n\n" + strings.Join(parts, "\n\n---\n\n")
ctx.PrintInfo(fmt.Sprintf("[project-rules] Loaded %d rule file(s) from .kit/rules/", len(parts)))
})
api.OnBeforeAgentStart(func(_ ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
if rules == "" {
return nil
}
return &ext.BeforeAgentStartResult{
SystemPrompt: &rules,
}
})
}
+114
View File
@@ -0,0 +1,114 @@
//go:build ignore
package main
import (
"encoding/json"
"strings"
"kit/ext"
)
// Init blocks tool calls that attempt to write, edit, or delete files in
// protected paths. Inspired by Pi's protected-paths.ts.
//
// Protected: .env*, .git/, secrets/, credentials*, *.pem, *.key
//
// Usage: kit -e examples/extensions/protected-paths.go
func Init(api ext.API) {
// Tools that modify files.
writeTools := map[string]bool{
"Write": true,
"Edit": true,
"Bash": true,
}
// Path patterns to protect (checked against the file_path / filePath field).
protectedPatterns := []string{
".env",
".git/",
"secrets/",
"credentials",
".pem",
".key",
"id_rsa",
"id_ed25519",
}
// Bash commands that could modify protected files.
bashWritePatterns := []string{
"rm ", "mv ", "cp ", "> ",
"cat >", "echo >", "tee ",
"chmod ", "chown ",
}
isProtected := func(path string) bool {
lower := strings.ToLower(path)
for _, p := range protectedPatterns {
if strings.Contains(lower, p) {
return true
}
}
return false
}
api.OnToolCall(func(tc ext.ToolCallEvent, ctx ext.Context) *ext.ToolCallResult {
if !writeTools[tc.ToolName] {
return nil
}
// For Write/Edit: check the file_path / filePath field.
if tc.ToolName == "Write" || tc.ToolName == "Edit" {
var input map[string]any
if err := json.Unmarshal([]byte(tc.Input), &input); err != nil {
return nil
}
// Try both naming conventions.
filePath, _ := input["file_path"].(string)
if filePath == "" {
filePath, _ = input["filePath"].(string)
}
if isProtected(filePath) {
return &ext.ToolCallResult{
Block: true,
Reason: "Blocked: writing to protected path: " + filePath,
}
}
return nil
}
// For Bash: check if the command references protected paths.
if tc.ToolName == "Bash" {
var input struct {
Command string `json:"command"`
}
if err := json.Unmarshal([]byte(tc.Input), &input); err != nil {
return nil
}
// Only check bash commands that look like file mutations.
isMutation := false
for _, pat := range bashWritePatterns {
if strings.Contains(input.Command, pat) {
isMutation = true
break
}
}
if !isMutation {
return nil
}
// Check if any protected pattern appears in the command.
for _, p := range protectedPatterns {
if strings.Contains(input.Command, p) {
return &ext.ToolCallResult{
Block: true,
Reason: "Blocked: bash command references protected path (" + p + "): " + input.Command,
}
}
}
}
return nil
})
}
+93
View File
@@ -0,0 +1,93 @@
//go:build ignore
package main
import (
"fmt"
"strings"
"kit/ext"
)
// Init adds a /summarize command that generates a concise summary of the
// current conversation using a direct LLM completion. Demonstrates the
// ctx.Complete API (Gap 17). Inspired by Pi's summarize.ts.
//
// The summary is displayed in a styled block and can optionally be saved
// to the session via AppendEntry for later retrieval.
//
// Usage: kit -e examples/extensions/summarize.go
func Init(api ext.API) {
api.RegisterCommand(ext.CommandDef{
Name: "summarize",
Description: "Summarize the current conversation",
Execute: func(args string, ctx ext.Context) (string, error) {
msgs := ctx.GetMessages()
if len(msgs) == 0 {
ctx.PrintInfo("Nothing to summarize — no messages yet.")
return "", nil
}
// Build a text representation of the conversation.
var parts []string
for _, m := range msgs {
content := m.Content
if len(content) > 2000 {
content = content[:1997] + "..."
}
parts = append(parts, fmt.Sprintf("[%s]: %s", m.Role, content))
}
conversation := strings.Join(parts, "\n\n")
ctx.PrintInfo("Generating summary...")
resp, err := ctx.Complete(ext.CompleteRequest{
System: `You are a concise summarization assistant. Summarize the conversation below in 3-5 bullet points. Focus on:
- What was discussed or requested
- Key decisions or outcomes
- Any pending action items
Be concise. Use plain text, no markdown headers.`,
Prompt: conversation,
})
if err != nil {
ctx.PrintError("Summary failed: " + err.Error())
return "", nil
}
summary := strings.TrimSpace(resp.Text)
ctx.PrintBlock(ext.PrintBlockOpts{
Text: summary,
BorderColor: "#89b4fa",
Subtitle: fmt.Sprintf("Summary (%d messages, %d tokens used)", len(msgs), resp.InputTokens+resp.OutputTokens),
})
// Persist the summary in the session for later retrieval.
ctx.AppendEntry("summary", summary)
return "", nil
},
})
// /summaries — list all saved summaries.
api.RegisterCommand(ext.CommandDef{
Name: "summaries",
Description: "List saved conversation summaries",
Execute: func(args string, ctx ext.Context) (string, error) {
entries := ctx.GetEntries("summary")
if len(entries) == 0 {
ctx.PrintInfo("No summaries saved yet. Use /summarize to create one.")
return "", nil
}
for i, e := range entries {
ctx.PrintBlock(ext.PrintBlockOpts{
Text: e.Data,
BorderColor: "#89b4fa",
Subtitle: fmt.Sprintf("Summary #%d (%s)", i+1, e.Timestamp[:19]),
})
}
return "", nil
},
})
}
+70
View File
@@ -74,6 +74,7 @@ type Agent struct {
streamingEnabled bool
coreTools []fantasy.AgentTool
extraTools []fantasy.AgentTool
toolWrapper func([]fantasy.AgentTool) []fantasy.AgentTool // stored for SetModel rebuild
}
// GenerateWithLoopResult contains the result and conversation history from an agent interaction.
@@ -179,6 +180,7 @@ func NewAgent(ctx context.Context, agentConfig *AgentConfig) (*Agent, error) {
streamingEnabled: agentConfig.StreamingEnabled,
coreTools: coreTools,
extraTools: agentConfig.ExtraTools,
toolWrapper: agentConfig.ToolWrapper,
}, nil
}
@@ -455,6 +457,11 @@ func (a *Agent) GetTools() []fantasy.AgentTool {
return allTools
}
// GetCoreToolCount returns the number of core tools.
func (a *Agent) GetCoreToolCount() int {
return len(a.coreTools)
}
// GetMCPToolCount returns the number of tools loaded from external MCP servers.
func (a *Agent) GetMCPToolCount() int {
if a.toolManager == nil {
@@ -481,6 +488,69 @@ func (a *Agent) GetLoadedServerNames() []string {
return a.toolManager.GetLoadedServerNames()
}
// SetModel swaps the agent's LLM provider to a new model. The existing tools,
// system prompt, and configuration are preserved. The old provider is closed
// if it has a closer. Returns the previous model string for notification.
func (a *Agent) SetModel(ctx context.Context, config *models.ProviderConfig) error {
providerResult, err := models.CreateProvider(ctx, config)
if err != nil {
return fmt.Errorf("failed to create model provider: %v", err)
}
// Rebuild tool list (same as NewAgent).
allTools := make([]fantasy.AgentTool, len(a.coreTools))
copy(allTools, a.coreTools)
if a.toolManager != nil {
allTools = append(allTools, a.toolManager.GetTools()...)
}
if len(a.extraTools) > 0 {
allTools = append(allTools, a.extraTools...)
}
if a.toolWrapper != nil {
allTools = a.toolWrapper(allTools)
}
// Rebuild fantasy agent options.
var agentOpts []fantasy.AgentOption
if a.systemPrompt != "" {
agentOpts = append(agentOpts, fantasy.WithSystemPrompt(a.systemPrompt))
}
if len(allTools) > 0 {
agentOpts = append(agentOpts, fantasy.WithTools(allTools...))
}
if a.maxSteps > 0 {
agentOpts = append(agentOpts, fantasy.WithStopConditions(
fantasy.StepCountIs(a.maxSteps),
))
}
newFantasyAgent := fantasy.NewAgent(providerResult.Model, agentOpts...)
// Close old provider.
if a.providerCloser != nil {
_ = a.providerCloser.Close()
}
// Update model info on MCP tool manager.
if a.toolManager != nil {
a.toolManager.SetModel(providerResult.Model)
}
// Swap fields.
a.fantasyAgent = newFantasyAgent
a.model = providerResult.Model
a.providerCloser = providerResult.Closer
// Update provider type.
if config.ModelString != "" {
if p, _, err := models.ParseModelString(config.ModelString); err == nil {
a.providerType = p
}
}
return nil
}
// GetModel returns the underlying fantasy LanguageModel.
func (a *Agent) GetModel() fantasy.LanguageModel {
return a.model
+39
View File
@@ -141,6 +141,34 @@ func (a *App) QueueLength() int {
return len(a.queue)
}
// Steer cancels the current agent step (if running), clears the queue, and
// sends a new message that will execute as soon as the current step finishes
// cancelling. If the agent is idle, the message executes immediately.
// This is the "steer" delivery mode for SendMessage.
func (a *App) Steer(prompt string) {
a.mu.Lock()
if a.closed {
a.mu.Unlock()
return
}
if !a.busy {
// Not busy — start immediately, same as Run().
a.busy = true
a.wg.Add(1)
a.mu.Unlock()
go a.drainQueue(prompt)
return
}
// Agent is busy: clear queue, insert steer message, then cancel.
a.queue = []string{prompt}
cancel := a.cancelStep
a.mu.Unlock()
cancel()
}
// ClearQueue discards all queued prompts. The caller is responsible for
// updating any UI state (e.g. queue badge) — ClearQueue does NOT send
// events to the program, because it may be called synchronously from
@@ -516,6 +544,17 @@ func (a *App) SetEditorTextFromExtension(text string) {
}
}
// NotifyModelChanged sends a ModelChangedEvent to the TUI so it updates
// the model name in the status bar and message attribution.
func (a *App) NotifyModelChanged(provider, model string) {
a.mu.Lock()
prog := a.program
a.mu.Unlock()
if prog != nil {
prog.Send(ModelChangedEvent{ProviderName: provider, ModelName: model})
}
}
// NotifyWidgetUpdate sends a WidgetUpdateEvent to the TUI so it re-renders
// extension widgets. Called from the extension context's SetWidget/RemoveWidget
// closures. In non-interactive mode this is a no-op (widgets are TUI-only).
+10
View File
@@ -113,6 +113,16 @@ type CompactErrorEvent struct {
Err error
}
// ModelChangedEvent is sent when an extension changes the active model via
// ctx.SetModel. The TUI updates the model name shown in the status bar and
// message attribution.
type ModelChangedEvent struct {
// ProviderName is the new provider (e.g. "anthropic").
ProviderName string
// ModelName is the new model ID (e.g. "claude-3-5-haiku-20241022").
ModelName string
}
// WidgetUpdateEvent is sent when an extension adds, updates, or removes a
// widget via ctx.SetWidget or ctx.RemoveWidget. The TUI re-reads widget state
// from its WidgetProvider on the next render cycle.
+254
View File
@@ -64,6 +64,19 @@ type Context struct {
// }()
SendMessage func(string)
// CancelAndSend cancels the current agent turn (if running), clears
// the message queue, and sends a new message that executes as soon as
// cancellation completes. If the agent is idle, the message executes
// immediately. This is the "steer" delivery mode.
//
// Use this for directive changes that should interrupt the current
// operation, e.g. switching modes or redirecting the agent.
//
// Example:
//
// ctx.CancelAndSend("Stop what you're doing and focus on the tests")
CancelAndSend func(string)
// SetWidget places or updates a persistent widget in the TUI. Widgets
// remain visible across agent turns until explicitly removed. The
// widget is identified by WidgetConfig.ID; calling SetWidget with the
@@ -300,6 +313,127 @@ type Context struct {
// RemoveStatus removes a keyed status bar entry. No-op if the key
// does not exist.
RemoveStatus func(key string)
// --- Extension Options (Gap 7) ---
// GetOption returns the value of a named extension option. Options are
// resolved in priority order:
// 1. Runtime override (via SetOption)
// 2. Environment variable: KIT_OPT_<NAME> (uppercase, dashes → underscores)
// 3. Config file: options.<name> in .kit.yml
// 4. Default value registered by the extension
//
// Returns empty string if the option was not registered.
//
// Example:
//
// preset := ctx.GetOption("preset")
// if preset == "fast" {
// ctx.SetModel("anthropic/claude-haiku-3-5-20241022")
// }
GetOption func(name string) string
// SetOption sets a runtime override for a named extension option. This
// takes highest priority over env vars, config, and defaults. Useful for
// persisting user choices during a session.
SetOption func(name string, value string)
// --- Model Management (Gap 2) ---
// SetModel changes the active LLM model at runtime. The model string
// should be in "provider/model" format (e.g. "anthropic/claude-sonnet-4-5-20250929").
// Existing tools, system prompt, and session are preserved. Returns an
// error if the model string is invalid or the provider cannot be created.
//
// Example:
//
// err := ctx.SetModel("openai/gpt-4o")
// if err != nil {
// ctx.PrintError("Failed to switch model: " + err.Error())
// }
SetModel func(modelString string) error
// GetAvailableModels returns a list of known models from the registry.
// This is an advisory list — models not in the registry can still be
// used by specifying their provider/model string directly.
//
// Example:
//
// models := ctx.GetAvailableModels()
// for _, m := range models {
// fmt.Printf("%s/%s (ctx: %dk)\n", m.Provider, m.ModelID, m.ContextLimit/1000)
// }
GetAvailableModels func() []ModelInfoEntry
// --- Inter-Extension Event Bus (Gap 13) ---
// EmitCustomEvent publishes a named event that other extensions can
// subscribe to via api.OnCustomEvent(). Data is an arbitrary string
// (JSON-encode complex payloads). Handlers run synchronously in
// registration order.
//
// Example:
//
// ctx.EmitCustomEvent("plan-mode:toggled", `{"active":true}`)
EmitCustomEvent func(name string, data string)
// --- Tool Management (Gap 3) ---
// GetAllTools returns information about all tools available to the agent,
// including core tools (bash, read, write, etc.), MCP server tools, and
// extension-registered tools. Each entry includes the tool's enabled status.
//
// Example — list read-only tools:
//
// for _, t := range ctx.GetAllTools() {
// if t.Source == "core" && t.Enabled {
// fmt.Println(t.Name, "-", t.Description)
// }
// }
GetAllTools func() []ToolInfo
// SetActiveTools restricts the agent to only the named tools. Tools not
// in the list are blocked from execution (the LLM receives an error if
// it tries to call them). Pass nil or an empty slice to re-enable all
// tools. Tool names are case-sensitive.
//
// Example — plan mode (read-only):
//
// ctx.SetActiveTools([]string{"Read", "Glob", "Grep", "LS"})
SetActiveTools func(names []string)
// --- Direct LLM Completion (Gap 17) ---
// Complete makes a standalone LLM completion call, bypassing the agent
// tool loop. Use this for summarisation, question extraction, or any
// sub-task that needs an LLM response without tool access.
//
// If Model is empty the current session model is reused (no extra
// provider creation overhead). Specify a different model string to
// use a cheaper/faster model for the sub-task.
//
// Example — summarise with a fast model:
//
// resp, err := ctx.Complete(ext.CompleteRequest{
// Model: "anthropic/claude-haiku-3-5-20241022",
// System: "You are a concise summarisation assistant.",
// Prompt: "Summarise this conversation:\n" + text,
// })
// if err != nil {
// ctx.PrintError("completion failed: " + err.Error())
// return
// }
// ctx.PrintInfo(resp.Text)
//
// Example — streaming completion:
//
// resp, err := ctx.Complete(ext.CompleteRequest{
// Prompt: "Explain quantum computing",
// OnChunk: func(chunk string) {
// fmt.Print(chunk) // stream to stdout
// },
// })
Complete func(CompleteRequest) (CompleteResponse, error)
}
// ---------------------------------------------------------------------------
@@ -340,6 +474,53 @@ type ExtensionEntry struct {
Timestamp string
}
// ---------------------------------------------------------------------------
// LLM completion types (exposed to Yaegi — concrete structs)
// ---------------------------------------------------------------------------
// CompleteRequest configures a standalone LLM completion call. Extensions use
// this with ctx.Complete() to make direct LLM calls without the agent tool loop.
type CompleteRequest struct {
// Model is the model to use in "provider/model" format (e.g.
// "anthropic/claude-haiku-3-5-20241022"). Empty string uses the current
// session model, avoiding extra provider creation overhead.
Model string
// Prompt is the user input text sent to the model.
Prompt string
// System is an optional system prompt. Empty uses no system prompt.
System string
// Messages is optional conversation history. If provided, Prompt is
// appended as the final user message.
Messages []SessionMessage
// MaxTokens limits the response length (0 = provider default).
MaxTokens int
// OnChunk is called for each streaming text delta. When set, the
// completion is performed in streaming mode. When nil, the call blocks
// until the full response is available.
OnChunk func(chunk string)
}
// CompleteResponse contains the LLM response and usage metadata from a
// standalone completion call.
type CompleteResponse struct {
// Text is the complete response text.
Text string
// InputTokens is the number of tokens in the request.
InputTokens int
// OutputTokens is the number of tokens in the response.
OutputTokens int
// Model is the actual model used (useful when CompleteRequest.Model was empty).
Model string
}
// ---------------------------------------------------------------------------
// Status bar types (exposed to Yaegi — concrete structs)
// ---------------------------------------------------------------------------
@@ -398,6 +579,8 @@ type API struct {
registerToolFn func(ToolDef)
registerCmdFn func(CommandDef)
registerToolRendererFn func(ToolRenderConfig)
onCustomEvent func(name string, handler func(string))
registerOption func(OptionDef)
}
// OnToolCall registers a handler that fires before a tool executes.
@@ -478,6 +661,22 @@ func (a *API) RegisterCommand(cmd CommandDef) {
a.registerCmdFn(cmd)
}
// RegisterOption declares a named configuration option. The option can be set
// via environment variables (KIT_OPT_<NAME>) or config file (options.<name>).
// Multiple extensions can register options with the same name; the last default
// wins.
func (a *API) RegisterOption(opt OptionDef) {
a.registerOption(opt)
}
// OnCustomEvent registers a handler for a custom inter-extension event.
// The handler receives the data string published by EmitCustomEvent.
// Multiple handlers can subscribe to the same event name; they execute
// in registration order.
func (a *API) OnCustomEvent(name string, handler func(string)) {
a.onCustomEvent(name, handler)
}
// RegisterToolRenderer registers a custom renderer for a specific tool's
// display in the TUI. The renderer controls the header (parameter summary)
// and/or body (result display) of the tool's output block. If multiple
@@ -742,6 +941,44 @@ type OverlayResult struct {
Cancelled bool
}
// ---------------------------------------------------------------------------
// Model info types (exposed to Yaegi — concrete structs)
// ---------------------------------------------------------------------------
// ModelInfoEntry represents a known model from the registry. Used by
// GetAvailableModels to let extensions discover which models are available.
type ModelInfoEntry struct {
// Provider is the provider ID (e.g. "anthropic", "openai").
Provider string
// ModelID is the model identifier (e.g. "claude-sonnet-4-5-20250929").
ModelID string
// Name is the human-readable model name.
Name string
// ContextLimit is the maximum context window in tokens (0 if unknown).
ContextLimit int
// OutputLimit is the maximum output tokens (0 if unknown).
OutputLimit int
// Reasoning is true if the model supports extended thinking.
Reasoning bool
}
// ---------------------------------------------------------------------------
// Tool info types (exposed to Yaegi — concrete structs)
// ---------------------------------------------------------------------------
// ToolInfo provides read-only information about a tool available to the agent.
// Used by GetAllTools to let extensions inspect and filter the tool set.
type ToolInfo struct {
// Name is the tool's unique identifier.
Name string
// Description is the tool's human-readable description.
Description string
// Source indicates where the tool came from: "core", "mcp", or "extension".
Source string
// Enabled is true if the tool is currently active.
Enabled bool
}
// ---------------------------------------------------------------------------
// ToolDef / CommandDef
// ---------------------------------------------------------------------------
@@ -761,6 +998,23 @@ type CommandDef struct {
Execute func(args string, ctx Context) (string, error)
}
// ---------------------------------------------------------------------------
// Extension options (exposed to Yaegi — concrete structs)
// ---------------------------------------------------------------------------
// OptionDef describes a configuration option that an extension can register.
// Options are resolved from env vars, config file, or default value.
type OptionDef struct {
// Name is the option identifier. Used as:
// - Env var: KIT_OPT_<NAME> (uppercased, dashes → underscores)
// - Config key: options.<name> in .kit.yml
Name string
// Description explains what the option controls.
Description string
// Default is the fallback value if not set via env or config.
Default string
}
// ---------------------------------------------------------------------------
// Custom tool rendering (exposed to Yaegi — concrete structs)
// ---------------------------------------------------------------------------
+9
View File
@@ -292,6 +292,15 @@ func loadSingleExtension(path string) (*LoadedExtension, error) {
registerToolRendererFn: func(config ToolRenderConfig) {
ext.ToolRenderers = append(ext.ToolRenderers, config)
},
onCustomEvent: func(name string, handler func(string)) {
if ext.CustomEventHandlers == nil {
ext.CustomEventHandlers = make(map[string][]func(string))
}
ext.CustomEventHandlers[name] = append(ext.CustomEventHandlers[name], handler)
},
registerOption: func(opt OptionDef) {
ext.Options = append(ext.Options, opt)
},
}
// Call Init — the extension registers its handlers, tools, commands.
+166 -14
View File
@@ -2,36 +2,44 @@ package extensions
import (
"fmt"
"os"
"sort"
"strings"
"sync"
"github.com/charmbracelet/log"
"github.com/spf13/viper"
)
// Runner manages loaded extensions and dispatches events to their handlers
// sequentially, mirroring Pi's ExtensionRunner. Handlers execute in extension
// load order; for cancellable events the first blocking result wins.
type Runner struct {
extensions []LoadedExtension
ctx Context
widgets map[string]WidgetConfig // keyed by widget ID
statusEntries map[string]StatusBarEntry // keyed by status key
header *HeaderFooterConfig // nil = no custom header
footer *HeaderFooterConfig // nil = no custom footer
customEditor *EditorConfig // nil = no custom editor interceptor
uiVisibility *UIVisibility // nil = show everything (default)
mu sync.RWMutex
extensions []LoadedExtension
ctx Context
widgets map[string]WidgetConfig // keyed by widget ID
statusEntries map[string]StatusBarEntry // keyed by status key
header *HeaderFooterConfig // nil = no custom header
footer *HeaderFooterConfig // nil = no custom footer
customEditor *EditorConfig // nil = no custom editor interceptor
uiVisibility *UIVisibility // nil = show everything (default)
disabledTools map[string]bool // nil = all tools enabled
customEventSubs map[string][]func(string) // inter-extension event bus
optionOverrides map[string]string // runtime option overrides
mu sync.RWMutex
}
// LoadedExtension represents a single extension that has been discovered,
// loaded, and initialised. It holds the registered handlers and any custom
// tools, commands, or tool renderers the extension provided.
type LoadedExtension struct {
Path string
Handlers map[EventType][]HandlerFunc
Tools []ToolDef
Commands []CommandDef
ToolRenderers []ToolRenderConfig
Path string
Handlers map[EventType][]HandlerFunc
Tools []ToolDef
Commands []CommandDef
ToolRenderers []ToolRenderConfig
CustomEventHandlers map[string][]func(string) // inter-extension event bus
Options []OptionDef // registered configuration options
}
// NewRunner creates a Runner from a set of loaded extensions.
@@ -354,6 +362,150 @@ func (r *Runner) GetToolRenderer(toolName string) *ToolRenderConfig {
return nil
}
// ---------------------------------------------------------------------------
// Inter-extension event bus
// ---------------------------------------------------------------------------
// SubscribeCustomEvent registers a handler for a named custom event. Handlers
// execute in registration order when EmitCustomEvent is called. Thread-safe.
func (r *Runner) SubscribeCustomEvent(name string, handler func(string)) {
r.mu.Lock()
defer r.mu.Unlock()
if r.customEventSubs == nil {
r.customEventSubs = make(map[string][]func(string))
}
r.customEventSubs[name] = append(r.customEventSubs[name], handler)
}
// EmitCustomEvent dispatches a named event to all subscribed handlers.
// Handlers run synchronously in extension load order. Panics are recovered
// and logged. Thread-safe.
func (r *Runner) EmitCustomEvent(name, data string) {
// Collect handlers: extension-registered (Init-time) + dynamic subs.
r.mu.RLock()
dynamicHandlers := r.customEventSubs[name]
r.mu.RUnlock()
safeInvoke := func(h func(string)) {
defer func() {
if rec := recover(); rec != nil {
log.Warn("custom event handler panicked",
"event", name,
"err", fmt.Sprintf("%v", rec))
}
}()
h(data)
}
// Extension-registered handlers first (in load order).
for i := range r.extensions {
for _, h := range r.extensions[i].CustomEventHandlers[name] {
safeInvoke(h)
}
}
// Then dynamic subscriptions.
for _, h := range dynamicHandlers {
safeInvoke(h)
}
}
// ---------------------------------------------------------------------------
// Tool management
// ---------------------------------------------------------------------------
// SetActiveTools restricts the tool set to the named tools. All tools not in
// the list are disabled. Passing nil or an empty slice re-enables all tools.
// Thread-safe.
func (r *Runner) SetActiveTools(names []string) {
r.mu.Lock()
defer r.mu.Unlock()
if len(names) == 0 {
r.disabledTools = nil
return
}
active := make(map[string]bool, len(names))
for _, n := range names {
active[n] = true
}
r.disabledTools = active // non-nil = only these tools are allowed
}
// IsToolDisabled returns true if the tool has been disabled via SetActiveTools.
// Thread-safe.
func (r *Runner) IsToolDisabled(toolName string) bool {
r.mu.RLock()
defer r.mu.RUnlock()
if r.disabledTools == nil {
return false // no filter = all enabled
}
return !r.disabledTools[toolName]
}
// ---------------------------------------------------------------------------
// Extension options
// ---------------------------------------------------------------------------
// GetOption resolves a named option value in priority order:
// 1. Runtime override (via SetOption)
// 2. Environment variable: KIT_OPT_<NAME> (uppercased, dashes → underscores)
// 3. Viper config: options.<name>
// 4. Default value from RegisterOption
//
// Returns empty string if the option was never registered.
// Thread-safe.
func (r *Runner) GetOption(name string) string {
// 1. Runtime override.
r.mu.RLock()
if v, ok := r.optionOverrides[name]; ok {
r.mu.RUnlock()
return v
}
r.mu.RUnlock()
// 2. Environment variable: KIT_OPT_<NAME>
envKey := "KIT_OPT_" + strings.ToUpper(strings.ReplaceAll(name, "-", "_"))
if v := os.Getenv(envKey); v != "" {
return v
}
// 3. Viper config: options.<name>
configKey := "options." + name
if v := viper.GetString(configKey); v != "" {
return v
}
// 4. Default from registered option defs.
for i := range r.extensions {
for _, opt := range r.extensions[i].Options {
if opt.Name == name {
return opt.Default
}
}
}
return ""
}
// SetOption stores a runtime override for a named option. This takes highest
// priority over env vars, config, and defaults. Thread-safe.
func (r *Runner) SetOption(name, value string) {
r.mu.Lock()
defer r.mu.Unlock()
if r.optionOverrides == nil {
r.optionOverrides = make(map[string]string)
}
r.optionOverrides[name] = value
}
// RegisteredOptions returns all option definitions from all loaded extensions.
func (r *Runner) RegisteredOptions() []OptionDef {
var opts []OptionDef
for i := range r.extensions {
opts = append(opts, r.extensions[i].Options...)
}
return opts
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
+13
View File
@@ -30,6 +30,19 @@ func Symbols() interp.Exports {
"SessionMessage": reflect.ValueOf((*SessionMessage)(nil)),
"ExtensionEntry": reflect.ValueOf((*ExtensionEntry)(nil)),
// Option types
"OptionDef": reflect.ValueOf((*OptionDef)(nil)),
// Model info types
"ModelInfoEntry": reflect.ValueOf((*ModelInfoEntry)(nil)),
// Tool info types
"ToolInfo": reflect.ValueOf((*ToolInfo)(nil)),
// LLM completion types
"CompleteRequest": reflect.ValueOf((*CompleteRequest)(nil)),
"CompleteResponse": reflect.ValueOf((*CompleteResponse)(nil)),
// Status bar types
"StatusBarEntry": reflect.ValueOf((*StatusBarEntry)(nil)),
+11 -5
View File
@@ -17,11 +17,10 @@ func WrapToolsWithExtensions(tools []fantasy.AgentTool, runner *Runner) []fantas
if runner == nil {
return tools
}
if !runner.HasHandlers(ToolCall) && !runner.HasHandlers(ToolResult) &&
!runner.HasHandlers(ToolExecutionStart) && !runner.HasHandlers(ToolExecutionEnd) {
return tools
}
// Always wrap tools through the runner so that SetActiveTools
// (disabled-tool checking) and event handlers both work. The
// overhead for disabled-tool checking is a single map lookup
// per tool call, which is negligible.
wrapped := make([]fantasy.AgentTool, len(tools))
for i, tool := range tools {
wrapped[i] = &wrappedTool{inner: tool, runner: runner}
@@ -55,6 +54,13 @@ func (w *wrappedTool) SetProviderOptions(o fantasy.ProviderOptions) { w.inner.Se
func (w *wrappedTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
toolName := w.inner.Info().Name
// 0. Check if tool is disabled via SetActiveTools.
if w.runner.IsToolDisabled(toolName) {
return fantasy.NewTextErrorResponse(
fmt.Sprintf("Error: tool %q is currently disabled", toolName)),
fmt.Errorf("tool %q disabled by extension", toolName)
}
// 1. Emit ToolCall — extensions can block execution.
if w.runner.HasHandlers(ToolCall) {
result, _ := w.runner.Emit(ToolCallEvent{
+7 -2
View File
@@ -48,8 +48,13 @@ func TestWrapToolsWithExtensions_NoRelevantHandlers(t *testing.T) {
}))
tools := []fantasy.AgentTool{newMockTool("test")}
result := WrapToolsWithExtensions(tools, r)
if result[0] != tools[0] {
t.Error("expected original tool when no tool handlers exist")
// Tools are always wrapped now (for SetActiveTools support),
// but Info() should pass through correctly.
if result[0] == tools[0] {
t.Error("expected wrapped tool (always wraps for SetActiveTools)")
}
if result[0].Info().Name != "test" {
t.Errorf("expected name 'test', got %q", result[0].Info().Name)
}
}
+6
View File
@@ -983,6 +983,12 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.state = stateInput
cmds = append(cmds, m.printSystemMessage(fmt.Sprintf("Compaction failed: %v", msg.Err)))
case app.ModelChangedEvent:
// Extension changed the model — update display name in status bar
// and message attribution.
m.providerName = msg.ProviderName
m.modelName = msg.ModelName
case app.WidgetUpdateEvent:
// Extension widget changed — recalculate height distribution so the
// stream region accounts for widget space. View() will read the
+230
View File
@@ -16,6 +16,7 @@ import (
"github.com/mark3labs/kit/internal/extensions"
"github.com/mark3labs/kit/internal/kitsetup"
"github.com/mark3labs/kit/internal/message"
"github.com/mark3labs/kit/internal/models"
"github.com/mark3labs/kit/internal/session"
"github.com/mark3labs/kit/internal/skills"
"github.com/mark3labs/kit/internal/tools"
@@ -389,6 +390,235 @@ func (m *Kit) GetExtensionStatusEntries() []extensions.StatusBarEntry {
return m.extRunner.GetStatusEntries()
}
// GetExtensionToolInfos returns information about all tools available to the
// agent, including enabled/disabled status from SetActiveTools. Each tool is
// categorized by source: "core", "mcp", or "extension".
func (m *Kit) GetExtensionToolInfos() []extensions.ToolInfo {
agentTools := m.agent.GetTools()
coreCount := m.agent.GetCoreToolCount()
mcpCount := m.agent.GetMCPToolCount()
result := make([]extensions.ToolInfo, 0, len(agentTools))
for i, t := range agentTools {
info := t.Info()
source := "core"
if i >= coreCount && i < coreCount+mcpCount {
source = "mcp"
} else if i >= coreCount+mcpCount {
source = "extension"
}
enabled := true
if m.extRunner != nil && m.extRunner.IsToolDisabled(info.Name) {
enabled = false
}
result = append(result, extensions.ToolInfo{
Name: info.Name,
Description: info.Description,
Source: source,
Enabled: enabled,
})
}
return result
}
// SetExtensionActiveTools restricts the tool set to the named tools. All
// other tools are blocked from execution. Pass nil to re-enable all tools.
// No-op if extensions are disabled.
func (m *Kit) SetExtensionActiveTools(names []string) {
if m.extRunner != nil {
m.extRunner.SetActiveTools(names)
}
}
// SetModel changes the active model at runtime. The existing tools, system
// prompt, and session are preserved. The model string should be in
// "provider/model" format (e.g. "anthropic/claude-sonnet-4-5-20250929").
// Returns an error if the model string is invalid or the provider cannot
// be created.
func (m *Kit) SetModel(ctx context.Context, modelString string) error {
// Validate the model string first.
if _, _, err := ParseModelString(modelString); err != nil {
return err
}
// Build a provider config from current settings, overriding the model.
config := &models.ProviderConfig{
ModelString: modelString,
ProviderAPIKey: viper.GetString("provider-api-key"),
ProviderURL: viper.GetString("provider-url"),
MaxTokens: viper.GetInt("max-tokens"),
TLSSkipVerify: viper.GetBool("tls-skip-verify"),
}
temperature := float32(viper.GetFloat64("temperature"))
config.Temperature = &temperature
topP := float32(viper.GetFloat64("top-p"))
config.TopP = &topP
topK := int32(viper.GetInt("top-k"))
config.TopK = &topK
if err := m.agent.SetModel(ctx, config); err != nil {
return err
}
m.modelString = modelString
// Update extension context's Model field.
if m.extRunner != nil {
extCtx := m.extRunner.GetContext()
extCtx.Model = modelString
m.extRunner.SetContext(extCtx)
}
return nil
}
// GetAvailableModels returns a list of known models from the registry. Each
// entry includes provider, model ID, context limit, and whether the model
// supports reasoning. This is an advisory list — models not in the registry
// can still be used by specifying their provider/model string.
func (m *Kit) GetAvailableModels() []extensions.ModelInfoEntry {
registry := models.GetGlobalRegistry()
var result []extensions.ModelInfoEntry
for _, providerID := range registry.GetFantasyProviders() {
modelsMap, err := registry.GetModelsForProvider(providerID)
if err != nil {
continue
}
for modelID, info := range modelsMap {
result = append(result, extensions.ModelInfoEntry{
Provider: providerID,
ModelID: modelID,
Name: info.Name,
ContextLimit: info.Limit.Context,
OutputLimit: info.Limit.Output,
Reasoning: info.Reasoning,
})
}
}
return result
}
// GetExtensionOption resolves a named extension option value.
func (m *Kit) GetExtensionOption(name string) string {
if m.extRunner == nil {
return ""
}
return m.extRunner.GetOption(name)
}
// SetExtensionOption stores a runtime override for a named extension option.
func (m *Kit) SetExtensionOption(name, value string) {
if m.extRunner != nil {
m.extRunner.SetOption(name, value)
}
}
// EmitExtensionCustomEvent dispatches a named event to all extension handlers.
// No-op if extensions are disabled.
func (m *Kit) EmitExtensionCustomEvent(name, data string) {
if m.extRunner != nil {
m.extRunner.EmitCustomEvent(name, data)
}
}
// ExecuteCompletion makes a standalone LLM completion call for extensions.
// When req.Model is empty the current agent model is reused (no provider
// creation overhead). When req.Model is set a temporary provider is created,
// used, and closed.
func (m *Kit) ExecuteCompletion(ctx context.Context, req extensions.CompleteRequest) (extensions.CompleteResponse, error) {
var (
llmModel fantasy.LanguageModel
closer func()
usedModel string
)
if req.Model == "" {
// Reuse the active agent's model.
llmModel = m.agent.GetModel()
usedModel = m.modelString
closer = func() {} // nothing to clean up
} else {
// Create a temporary provider for the requested model.
config := &models.ProviderConfig{
ModelString: req.Model,
TLSSkipVerify: viper.GetBool("tls-skip-verify"),
}
if req.MaxTokens > 0 {
config.MaxTokens = req.MaxTokens
}
providerResult, err := models.CreateProvider(ctx, config)
if err != nil {
return extensions.CompleteResponse{}, fmt.Errorf("create provider for %q: %w", req.Model, err)
}
llmModel = providerResult.Model
usedModel = req.Model
closer = func() {
if providerResult.Closer != nil {
_ = providerResult.Closer.Close()
}
}
}
defer closer()
// Build fantasy agent options (no tools — just a simple completion).
var agentOpts []fantasy.AgentOption
if req.System != "" {
agentOpts = append(agentOpts, fantasy.WithSystemPrompt(req.System))
}
if req.MaxTokens > 0 {
agentOpts = append(agentOpts, fantasy.WithMaxOutputTokens(int64(req.MaxTokens)))
}
completionAgent := fantasy.NewAgent(llmModel, agentOpts...)
// Convert extension SessionMessage history to fantasy.Message slice.
var messages []fantasy.Message
for _, sm := range req.Messages {
messages = append(messages, fantasy.Message{
Role: fantasy.MessageRole(sm.Role),
Content: []fantasy.MessagePart{
fantasy.TextPart{Text: sm.Content},
},
})
}
// Streaming path.
if req.OnChunk != nil {
result, err := completionAgent.Stream(ctx, fantasy.AgentStreamCall{
Prompt: req.Prompt,
Messages: messages,
OnTextDelta: func(_, text string) error {
req.OnChunk(text)
return nil
},
})
if err != nil {
return extensions.CompleteResponse{}, fmt.Errorf("streaming completion: %w", err)
}
return extensions.CompleteResponse{
Text: result.Response.Content.Text(),
InputTokens: int(result.Response.Usage.InputTokens),
OutputTokens: int(result.Response.Usage.OutputTokens),
Model: usedModel,
}, nil
}
// Non-streaming path.
result, err := completionAgent.Generate(ctx, fantasy.AgentCall{
Prompt: req.Prompt,
Messages: messages,
})
if err != nil {
return extensions.CompleteResponse{}, fmt.Errorf("completion: %w", err)
}
return extensions.CompleteResponse{
Text: result.Response.Content.Text(),
InputTokens: int(result.Response.Usage.InputTokens),
OutputTokens: int(result.Response.Usage.OutputTokens),
Model: usedModel,
}, nil
}
// HasExtensions returns true if the extension runner is configured and active.
func (m *Kit) HasExtensions() bool {
return m.extRunner != nil