mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 03:30:26 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c2f2bdb3d3 | |||
| 201d14804e | |||
| 7e54710d4a | |||
| 88870be4d2 | |||
| 46bf809715 |
+109
-11
@@ -7,6 +7,7 @@ import (
|
||||
"image/color"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
@@ -18,6 +19,7 @@ import (
|
||||
"github.com/mark3labs/kit/internal/prompts"
|
||||
"github.com/mark3labs/kit/internal/ui"
|
||||
"github.com/mark3labs/kit/internal/ui/commands"
|
||||
"github.com/mark3labs/kit/internal/watcher"
|
||||
kit "github.com/mark3labs/kit/pkg/kit"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
@@ -48,12 +50,14 @@ var (
|
||||
noSessionFlag bool // --no-session: ephemeral mode, no persistence
|
||||
|
||||
// Model generation parameters
|
||||
maxTokens int
|
||||
temperature float32
|
||||
topP float32
|
||||
topK int32
|
||||
stopSequences []string
|
||||
thinkingLevel string
|
||||
maxTokens int
|
||||
temperature float32
|
||||
topP float32
|
||||
topK int32
|
||||
frequencyPenalty float32
|
||||
presencePenalty float32
|
||||
stopSequences []string
|
||||
thinkingLevel string
|
||||
|
||||
// Ollama-specific parameters
|
||||
numGPU int32
|
||||
@@ -291,6 +295,8 @@ func init() {
|
||||
flags.Float32Var(&temperature, "temperature", 0.7, "controls randomness in responses (0.0-1.0)")
|
||||
flags.Float32Var(&topP, "top-p", 0.95, "controls diversity via nucleus sampling (0.0-1.0)")
|
||||
flags.Int32Var(&topK, "top-k", 40, "controls diversity by limiting top K tokens to sample from")
|
||||
flags.Float32Var(&frequencyPenalty, "frequency-penalty", 0.0, "penalizes tokens based on frequency of appearance (0.0-2.0)")
|
||||
flags.Float32Var(&presencePenalty, "presence-penalty", 0.0, "penalizes tokens based on whether they have appeared (0.0-2.0)")
|
||||
flags.StringSliceVar(&stopSequences, "stop-sequences", nil, "custom stop sequences (comma-separated)")
|
||||
flags.StringVar(&thinkingLevel, "thinking-level", "off", "extended thinking level: off, minimal, low, medium, high")
|
||||
|
||||
@@ -313,6 +319,8 @@ func init() {
|
||||
_ = viper.BindPFlag("temperature", rootCmd.PersistentFlags().Lookup("temperature"))
|
||||
_ = viper.BindPFlag("top-p", rootCmd.PersistentFlags().Lookup("top-p"))
|
||||
_ = viper.BindPFlag("top-k", rootCmd.PersistentFlags().Lookup("top-k"))
|
||||
_ = viper.BindPFlag("frequency-penalty", rootCmd.PersistentFlags().Lookup("frequency-penalty"))
|
||||
_ = viper.BindPFlag("presence-penalty", rootCmd.PersistentFlags().Lookup("presence-penalty"))
|
||||
_ = viper.BindPFlag("stop-sequences", rootCmd.PersistentFlags().Lookup("stop-sequences"))
|
||||
_ = viper.BindPFlag("thinking-level", rootCmd.PersistentFlags().Lookup("thinking-level"))
|
||||
_ = viper.BindPFlag("num-gpu-layers", rootCmd.PersistentFlags().Lookup("num-gpu-layers"))
|
||||
@@ -1614,6 +1622,49 @@ func runNormalMode(ctx context.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
// Build prompt template and skill item provider callbacks for hot-reload.
|
||||
// These are called by the TUI when ContentReloadEvent fires.
|
||||
getPromptTemplates := func() []*prompts.PromptTemplate {
|
||||
if noPromptTemplates {
|
||||
return nil
|
||||
}
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
cwd, _ := os.Getwd()
|
||||
tpls, _, err := prompts.LoadAll(prompts.LoadOptions{
|
||||
Cwd: cwd,
|
||||
HomeDir: homeDir,
|
||||
ExtraPaths: promptTemplatePaths,
|
||||
ConfigPaths: viper.GetStringSlice("prompts"),
|
||||
IncludeDefaults: true,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Warning: failed to reload prompt templates: %v", err)
|
||||
}
|
||||
return tpls
|
||||
}
|
||||
|
||||
getSkillItems := func() []ui.SkillItem {
|
||||
// Re-discover skills from disk.
|
||||
if err := kitInstance.ReloadSkills(); err != nil {
|
||||
log.Printf("Warning: failed to reload skills: %v", err)
|
||||
return nil
|
||||
}
|
||||
cwd, _ := os.Getwd()
|
||||
var items []ui.SkillItem
|
||||
for _, s := range kitInstance.GetSkills() {
|
||||
source := "user"
|
||||
if strings.HasPrefix(s.Path, cwd) {
|
||||
source = "project"
|
||||
}
|
||||
items = append(items, ui.SkillItem{
|
||||
Name: s.Name,
|
||||
Path: s.Path,
|
||||
Source: source,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// Build extension UI providers once (shared between both modes).
|
||||
getWidgets := widgetProviderForUI(kitInstance)
|
||||
getHeader := headerProviderForUI(kitInstance)
|
||||
@@ -1709,9 +1760,54 @@ func runNormalMode(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Start file watchers for automatic prompt and skill hot-reload.
|
||||
{
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
cwd, _ := os.Getwd()
|
||||
|
||||
// Collect prompt template directories.
|
||||
promptDirs := watcher.CollectDirs(
|
||||
[]string{
|
||||
filepath.Join(homeDir, ".kit", "prompts"),
|
||||
filepath.Join(cwd, ".kit", "prompts"),
|
||||
},
|
||||
append(promptTemplatePaths, viper.GetStringSlice("prompts")...),
|
||||
)
|
||||
|
||||
// Collect skill directories.
|
||||
skillDirs := watcher.CollectDirs(
|
||||
[]string{
|
||||
filepath.Join(homeDir, ".config", "kit", "skills"),
|
||||
filepath.Join(cwd, ".agents", "skills"),
|
||||
filepath.Join(cwd, ".kit", "skills"),
|
||||
},
|
||||
nil,
|
||||
)
|
||||
|
||||
// Combine all content directories and start a single watcher.
|
||||
allContentDirs := append(promptDirs, skillDirs...)
|
||||
if len(allContentDirs) > 0 {
|
||||
contentWatcher, watchErr := watcher.New(watcher.Options{
|
||||
Dirs: allContentDirs,
|
||||
Extensions: []string{".md", ".txt"},
|
||||
Label: "prompts/skills",
|
||||
OnReload: func() {
|
||||
log.Printf("auto-reloading prompts and skills")
|
||||
appInstance.NotifyContentReload()
|
||||
},
|
||||
})
|
||||
if watchErr != nil {
|
||||
log.Printf("content file watcher not started: %v", watchErr)
|
||||
} else {
|
||||
go contentWatcher.Start(ctx)
|
||||
defer func() { _ = contentWatcher.Close() }()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if running in non-interactive mode
|
||||
if positionalPrompt != "" {
|
||||
return runNonInteractiveModeApp(ctx, appInstance, cli, positionalPrompt, quietFlag, jsonFlag, noExitFlag, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, promptTemplates, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModelForUI, emitModelChangeForUI, kitInstance.IsReasoningModel(), kitInstance.GetThinkingLevel(), setThinkingLevelForUI, switchSessionForUI, reloadExtensionsForUI)
|
||||
return runNonInteractiveModeApp(ctx, appInstance, cli, positionalPrompt, quietFlag, jsonFlag, noExitFlag, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, promptTemplates, contextPaths, skillItems, getPromptTemplates, getSkillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModelForUI, emitModelChangeForUI, kitInstance.IsReasoningModel(), kitInstance.GetThinkingLevel(), setThinkingLevelForUI, switchSessionForUI, reloadExtensionsForUI)
|
||||
}
|
||||
|
||||
// Quiet mode is not allowed in interactive mode
|
||||
@@ -1719,7 +1815,7 @@ func runNormalMode(ctx context.Context) error {
|
||||
return fmt.Errorf("--quiet requires a prompt")
|
||||
}
|
||||
|
||||
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, promptTemplates, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModelForUI, emitModelChangeForUI, kitInstance.IsReasoningModel(), kitInstance.GetThinkingLevel(), setThinkingLevelForUI, switchSessionForUI, reloadExtensionsForUI, startupExtensionMessages)
|
||||
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, promptTemplates, contextPaths, skillItems, getPromptTemplates, getSkillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModelForUI, emitModelChangeForUI, kitInstance.IsReasoningModel(), kitInstance.GetThinkingLevel(), setThinkingLevelForUI, switchSessionForUI, reloadExtensionsForUI, startupExtensionMessages)
|
||||
}
|
||||
|
||||
// runNonInteractiveModeApp executes a single prompt via the app layer and exits,
|
||||
@@ -1732,7 +1828,7 @@ func runNormalMode(ctx context.Context) error {
|
||||
//
|
||||
// When --no-exit is set, after the prompt completes the interactive BubbleTea
|
||||
// TUI is started so the user can continue the conversation.
|
||||
func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui.CLI, prompt string, quiet, jsonOutput, noExit bool, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []commands.ExtensionCommand, promptTemplates []*prompts.PromptTemplate, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func(), getExtensionCommands func() []commands.ExtensionCommand, setModel func(string) error, emitModelChange func(string, string, string), isReasoningModel bool, thinkingLevel string, setThinkingLevel func(string) error, switchSession func(string) error, reloadExtensions func() error) error {
|
||||
func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui.CLI, prompt string, quiet, jsonOutput, noExit bool, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []commands.ExtensionCommand, promptTemplates []*prompts.PromptTemplate, contextPaths []string, skillItems []ui.SkillItem, getPromptTemplates func() []*prompts.PromptTemplate, getSkillItems func() []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func(), getExtensionCommands func() []commands.ExtensionCommand, setModel func(string) error, emitModelChange func(string, string, string), isReasoningModel bool, thinkingLevel string, setThinkingLevel func(string) error, switchSession func(string) error, reloadExtensions func() error) error {
|
||||
// Expand @file references in the prompt before sending to the agent.
|
||||
if cwd, err := os.Getwd(); err == nil {
|
||||
prompt = ui.ProcessFileAttachments(prompt, cwd)
|
||||
@@ -1775,7 +1871,7 @@ func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui
|
||||
|
||||
// If --no-exit was requested, hand off to the interactive TUI.
|
||||
if noExit {
|
||||
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, providerName, loadingMessage, serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, promptTemplates, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModel, emitModelChange, isReasoningModel, thinkingLevel, setThinkingLevel, switchSession, reloadExtensions, nil)
|
||||
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, providerName, loadingMessage, serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, promptTemplates, contextPaths, skillItems, getPromptTemplates, getSkillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModel, emitModelChange, isReasoningModel, thinkingLevel, setThinkingLevel, switchSession, reloadExtensions, nil)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -1873,7 +1969,7 @@ func writeJSONError(err error) {
|
||||
// 4. Calls program.Run() which blocks until the user quits (Ctrl+C or /quit).
|
||||
//
|
||||
// SetupCLI is not used for interactive mode; the TUI (AppModel) handles its own rendering.
|
||||
func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []commands.ExtensionCommand, promptTemplates []*prompts.PromptTemplate, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func(), getExtensionCommands func() []commands.ExtensionCommand, setModel func(string) error, emitModelChange func(string, string, string), isReasoningModel bool, thinkingLevel string, setThinkingLevel func(string) error, switchSession func(string) error, reloadExtensions func() error, startupExtensionMessages []string) error {
|
||||
func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []commands.ExtensionCommand, promptTemplates []*prompts.PromptTemplate, contextPaths []string, skillItems []ui.SkillItem, getPromptTemplates func() []*prompts.PromptTemplate, getSkillItems func() []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func(), getExtensionCommands func() []commands.ExtensionCommand, setModel func(string) error, emitModelChange func(string, string, string), isReasoningModel bool, thinkingLevel string, setThinkingLevel func(string) error, switchSession func(string) error, reloadExtensions func() error, startupExtensionMessages []string) error {
|
||||
// Determine terminal size; fall back gracefully.
|
||||
termWidth, termHeight, err := term.GetSize(int(os.Stdout.Fd()))
|
||||
if err != nil || termWidth == 0 {
|
||||
@@ -1897,8 +1993,10 @@ func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelN
|
||||
UsageTracker: usageTracker,
|
||||
ExtensionCommands: extCommands,
|
||||
PromptTemplates: promptTemplates,
|
||||
GetPromptTemplates: getPromptTemplates,
|
||||
ContextPaths: contextPaths,
|
||||
SkillItems: skillItems,
|
||||
GetSkillItems: getSkillItems,
|
||||
StartupExtensionMessages: startupExtensionMessages,
|
||||
GetWidgets: getWidgets,
|
||||
GetHeader: getHeader,
|
||||
|
||||
+194
-138
@@ -88,6 +88,10 @@ type StepUsageHandler func(inputTokens, outputTokens, cacheReadTokens, cacheCrea
|
||||
// Core tools (bash, read, write, edit, grep, find, ls) are registered as direct
|
||||
// AgentTool implementations — no MCP layer, no serialization overhead.
|
||||
// Additional tools from external MCP servers can be loaded alongside core tools.
|
||||
//
|
||||
// When MCP servers are configured, tool loading happens in the background so the
|
||||
// agent (and UI) can start immediately. The first LLM call automatically waits
|
||||
// for MCP tools to finish loading before proceeding.
|
||||
type Agent struct {
|
||||
toolManager *tools.MCPToolManager
|
||||
fantasyAgent fantasy.Agent
|
||||
@@ -101,6 +105,18 @@ type Agent struct {
|
||||
coreTools []fantasy.AgentTool
|
||||
extraTools []fantasy.AgentTool
|
||||
toolWrapper func([]fantasy.AgentTool) []fantasy.AgentTool // stored for SetModel rebuild
|
||||
|
||||
// providerOptions and modelConfig are stored for rebuilding the fantasy
|
||||
// agent when MCP tools arrive asynchronously or on SetModel.
|
||||
providerOptions fantasy.ProviderOptions
|
||||
skipMaxOutputTokens bool
|
||||
modelConfig *models.ProviderConfig
|
||||
|
||||
// mcpReady is closed when background MCP tool loading completes (success
|
||||
// or failure). nil when no MCP servers are configured.
|
||||
mcpReady chan struct{}
|
||||
// mcpErr holds any error from background MCP loading.
|
||||
mcpErr error
|
||||
}
|
||||
|
||||
// GenerateWithLoopResult contains the result and conversation history from an agent interaction.
|
||||
@@ -119,7 +135,10 @@ type GenerateWithLoopResult struct {
|
||||
|
||||
// NewAgent creates a new Agent with core tools and optional MCP tool integration.
|
||||
// Core tools (bash, read, write, edit, grep, find, ls) are always registered.
|
||||
// External MCP tools are loaded from the config if any MCP servers are configured.
|
||||
// If MCP servers are configured, their tools are loaded in the background —
|
||||
// the agent returns immediately and is usable with core tools only. The first
|
||||
// LLM call (GenerateWithLoop) automatically waits for MCP tools to finish
|
||||
// loading and rebuilds the agent with the full tool set.
|
||||
func NewAgent(ctx context.Context, agentConfig *AgentConfig) (*Agent, error) {
|
||||
// Create the LLM provider
|
||||
providerResult, err := models.CreateProvider(ctx, agentConfig.ModelConfig)
|
||||
@@ -134,33 +153,9 @@ func NewAgent(ctx context.Context, agentConfig *AgentConfig) (*Agent, error) {
|
||||
coreTools = core.AllTools()
|
||||
}
|
||||
|
||||
// Build the combined tool list: core tools + any external MCP tools
|
||||
// Build the initial tool list: core tools + extension tools (no MCP yet).
|
||||
allTools := make([]fantasy.AgentTool, len(coreTools))
|
||||
copy(allTools, coreTools)
|
||||
|
||||
// Load external MCP tools if configured
|
||||
var toolManager *tools.MCPToolManager
|
||||
if agentConfig.MCPConfig != nil && len(agentConfig.MCPConfig.MCPServers) > 0 {
|
||||
toolManager = tools.NewMCPToolManager()
|
||||
toolManager.SetModel(providerResult.Model)
|
||||
|
||||
if agentConfig.AuthHandler != nil {
|
||||
toolManager.SetAuthHandler(agentConfig.AuthHandler)
|
||||
}
|
||||
|
||||
if agentConfig.DebugLogger != nil {
|
||||
toolManager.SetDebugLogger(agentConfig.DebugLogger)
|
||||
}
|
||||
|
||||
if err := toolManager.LoadTools(ctx, agentConfig.MCPConfig); err != nil {
|
||||
// MCP tool loading failures are non-fatal; core tools still work
|
||||
fmt.Printf("Warning: Failed to load MCP tools: %v\n", err)
|
||||
} else {
|
||||
mcpTools := toolManager.GetTools()
|
||||
allTools = append(allTools, mcpTools...)
|
||||
}
|
||||
}
|
||||
|
||||
// Append any extra tools provided by extensions.
|
||||
if len(agentConfig.ExtraTools) > 0 {
|
||||
allTools = append(allTools, agentConfig.ExtraTools...)
|
||||
@@ -172,6 +167,140 @@ func NewAgent(ctx context.Context, agentConfig *AgentConfig) (*Agent, error) {
|
||||
}
|
||||
|
||||
// Build agent options
|
||||
agentOpts := buildAgentOptions(agentConfig, providerResult, allTools)
|
||||
|
||||
// Create the agent
|
||||
fantasyAgent := fantasy.NewAgent(providerResult.Model, agentOpts...)
|
||||
|
||||
// Determine provider type from model string
|
||||
providerType := "default"
|
||||
if agentConfig.ModelConfig != nil && agentConfig.ModelConfig.ModelString != "" {
|
||||
if p, _, err := models.ParseModelString(agentConfig.ModelConfig.ModelString); err == nil {
|
||||
providerType = p
|
||||
}
|
||||
}
|
||||
|
||||
a := &Agent{
|
||||
fantasyAgent: fantasyAgent,
|
||||
model: providerResult.Model,
|
||||
providerCloser: providerResult.Closer,
|
||||
maxSteps: agentConfig.MaxSteps,
|
||||
systemPrompt: agentConfig.SystemPrompt,
|
||||
loadingMessage: providerResult.Message,
|
||||
providerType: providerType,
|
||||
streamingEnabled: agentConfig.StreamingEnabled,
|
||||
coreTools: coreTools,
|
||||
extraTools: agentConfig.ExtraTools,
|
||||
toolWrapper: agentConfig.ToolWrapper,
|
||||
providerOptions: providerResult.ProviderOptions,
|
||||
skipMaxOutputTokens: providerResult.SkipMaxOutputTokens,
|
||||
modelConfig: agentConfig.ModelConfig,
|
||||
}
|
||||
|
||||
// Start MCP tool loading in the background if servers are configured.
|
||||
// The mcpReady channel is closed when loading completes (success or failure).
|
||||
if agentConfig.MCPConfig != nil && len(agentConfig.MCPConfig.MCPServers) > 0 {
|
||||
toolManager := tools.NewMCPToolManager()
|
||||
toolManager.SetModel(providerResult.Model)
|
||||
if agentConfig.AuthHandler != nil {
|
||||
toolManager.SetAuthHandler(agentConfig.AuthHandler)
|
||||
}
|
||||
if agentConfig.DebugLogger != nil {
|
||||
toolManager.SetDebugLogger(agentConfig.DebugLogger)
|
||||
}
|
||||
a.toolManager = toolManager
|
||||
a.mcpReady = make(chan struct{})
|
||||
|
||||
go func() {
|
||||
defer close(a.mcpReady)
|
||||
if err := toolManager.LoadTools(ctx, agentConfig.MCPConfig); err != nil {
|
||||
a.mcpErr = err
|
||||
fmt.Printf("Warning: Failed to load MCP tools: %v\n", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// WaitForMCPTools blocks until background MCP tool loading completes.
|
||||
// Returns nil if no MCP servers are configured or if loading succeeded.
|
||||
// Returns the loading error if all servers failed. Safe to call multiple times.
|
||||
func (a *Agent) WaitForMCPTools() error {
|
||||
if a.mcpReady == nil {
|
||||
return nil
|
||||
}
|
||||
<-a.mcpReady
|
||||
return a.mcpErr
|
||||
}
|
||||
|
||||
// MCPToolsReady returns true if MCP tool loading has completed (or was never
|
||||
// started). This is a non-blocking check useful for UI status display.
|
||||
func (a *Agent) MCPToolsReady() bool {
|
||||
if a.mcpReady == nil {
|
||||
return true
|
||||
}
|
||||
select {
|
||||
case <-a.mcpReady:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ensureMCPTools waits for MCP tools to load and rebuilds the fantasy agent
|
||||
// with the full tool set. Called lazily before the first LLM call.
|
||||
// This is idempotent — subsequent calls after the first rebuild are no-ops.
|
||||
func (a *Agent) ensureMCPTools() {
|
||||
if a.mcpReady == nil {
|
||||
return
|
||||
}
|
||||
<-a.mcpReady
|
||||
|
||||
// If there are MCP tools, rebuild the fantasy agent to include them.
|
||||
if a.toolManager != nil && len(a.toolManager.GetTools()) > 0 {
|
||||
a.rebuildFantasyAgent()
|
||||
}
|
||||
|
||||
// Nil out the channel so future calls are instant no-ops and we
|
||||
// don't rebuild again.
|
||||
a.mcpReady = nil
|
||||
}
|
||||
|
||||
// rebuildFantasyAgent reconstructs the fantasy agent with the current full
|
||||
// tool set (core + MCP + extension tools). Used after MCP tools arrive
|
||||
// asynchronously and by SetModel.
|
||||
func (a *Agent) rebuildFantasyAgent() {
|
||||
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)
|
||||
}
|
||||
|
||||
providerResult := &models.ProviderResult{
|
||||
Model: a.model,
|
||||
ProviderOptions: a.providerOptions,
|
||||
SkipMaxOutputTokens: a.skipMaxOutputTokens,
|
||||
}
|
||||
agentOpts := buildAgentOptions(&AgentConfig{
|
||||
ModelConfig: a.modelConfig,
|
||||
SystemPrompt: a.systemPrompt,
|
||||
MaxSteps: a.maxSteps,
|
||||
}, providerResult, allTools)
|
||||
|
||||
a.fantasyAgent = fantasy.NewAgent(a.model, agentOpts...)
|
||||
}
|
||||
|
||||
// buildAgentOptions constructs the fantasy.AgentOption slice from config,
|
||||
// provider result, and the combined tool list. Shared by NewAgent,
|
||||
// rebuildFantasyAgent, and SetModel.
|
||||
func buildAgentOptions(agentConfig *AgentConfig, providerResult *models.ProviderResult, allTools []fantasy.AgentTool) []fantasy.AgentOption {
|
||||
var agentOpts []fantasy.AgentOption
|
||||
|
||||
if agentConfig.SystemPrompt != "" {
|
||||
@@ -209,33 +338,15 @@ func NewAgent(ctx context.Context, agentConfig *AgentConfig) (*Agent, error) {
|
||||
if agentConfig.ModelConfig.TopK != nil {
|
||||
agentOpts = append(agentOpts, fantasy.WithTopK(int64(*agentConfig.ModelConfig.TopK)))
|
||||
}
|
||||
}
|
||||
|
||||
// Create the agent
|
||||
fantasyAgent := fantasy.NewAgent(providerResult.Model, agentOpts...)
|
||||
|
||||
// Determine provider type from model string
|
||||
providerType := "default"
|
||||
if agentConfig.ModelConfig != nil && agentConfig.ModelConfig.ModelString != "" {
|
||||
if p, _, err := models.ParseModelString(agentConfig.ModelConfig.ModelString); err == nil {
|
||||
providerType = p
|
||||
if agentConfig.ModelConfig.FrequencyPenalty != nil {
|
||||
agentOpts = append(agentOpts, fantasy.WithFrequencyPenalty(float64(*agentConfig.ModelConfig.FrequencyPenalty)))
|
||||
}
|
||||
if agentConfig.ModelConfig.PresencePenalty != nil {
|
||||
agentOpts = append(agentOpts, fantasy.WithPresencePenalty(float64(*agentConfig.ModelConfig.PresencePenalty)))
|
||||
}
|
||||
}
|
||||
|
||||
return &Agent{
|
||||
toolManager: toolManager,
|
||||
fantasyAgent: fantasyAgent,
|
||||
model: providerResult.Model,
|
||||
providerCloser: providerResult.Closer,
|
||||
maxSteps: agentConfig.MaxSteps,
|
||||
systemPrompt: agentConfig.SystemPrompt,
|
||||
loadingMessage: providerResult.Message,
|
||||
providerType: providerType,
|
||||
streamingEnabled: agentConfig.StreamingEnabled,
|
||||
coreTools: coreTools,
|
||||
extraTools: agentConfig.ExtraTools,
|
||||
toolWrapper: agentConfig.ToolWrapper,
|
||||
}, nil
|
||||
return agentOpts
|
||||
}
|
||||
|
||||
// GenerateWithLoop processes messages with a custom loop that displays tool calls in real-time.
|
||||
@@ -260,6 +371,11 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
|
||||
onStepUsage StepUsageHandler,
|
||||
) (*GenerateWithLoopResult, error) {
|
||||
|
||||
// Wait for background MCP tool loading to complete and rebuild the
|
||||
// fantasy agent with the full tool set. This is a no-op when no MCP
|
||||
// servers are configured or tools have already been integrated.
|
||||
a.ensureMCPTools()
|
||||
|
||||
// Inject tool output handler into context for use by core tools (e.g., bash).
|
||||
if onToolOutput != nil {
|
||||
ctx = core.ContextWithToolOutputCallback(ctx, onToolOutput)
|
||||
@@ -460,9 +576,12 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Fire the response callback for callers that use it (e.g. non-streaming
|
||||
// callers that still want the final response notification).
|
||||
if onResponse != nil && result.Response.Content.Text() != "" {
|
||||
// Fire the response callback so callers (e.g. the TUI) can reset
|
||||
// streaming state. This must fire even when the response text is
|
||||
// empty (e.g. reasoning-only responses) so the UI properly resets
|
||||
// the stream component and avoids duplicate content on the next
|
||||
// flush.
|
||||
if onResponse != nil {
|
||||
onResponse(result.Response.Content.Text())
|
||||
}
|
||||
|
||||
@@ -479,8 +598,9 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// For non-streaming, fire the response callback with the final text
|
||||
if onResponse != nil && result.Response.Content.Text() != "" {
|
||||
// For non-streaming, fire the response callback so callers can reset
|
||||
// streaming state (see streaming path comment above).
|
||||
if onResponse != nil {
|
||||
onResponse(result.Response.Content.Text())
|
||||
}
|
||||
|
||||
@@ -651,38 +771,9 @@ func (a *Agent) GetExtensionToolCount() int {
|
||||
// SetExtraTools replaces the agent's extra tools (e.g. extension-registered
|
||||
// tools) and rebuilds the internal agent with the updated tool list. The
|
||||
// model, system prompt, and all other configuration are preserved.
|
||||
func (a *Agent) SetExtraTools(tools []fantasy.AgentTool) {
|
||||
a.extraTools = tools
|
||||
|
||||
// Rebuild tool list (same as NewAgent / SetModel).
|
||||
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 agent options with the existing model.
|
||||
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),
|
||||
))
|
||||
}
|
||||
|
||||
// Swap the fantasy agent (model and provider are unchanged).
|
||||
a.fantasyAgent = fantasy.NewAgent(a.model, agentOpts...)
|
||||
func (a *Agent) SetExtraTools(extraTools []fantasy.AgentTool) {
|
||||
a.extraTools = extraTools
|
||||
a.rebuildFantasyAgent()
|
||||
}
|
||||
|
||||
// GetLoadingMessage returns the loading message from provider creation.
|
||||
@@ -702,60 +793,14 @@ func (a *Agent) GetLoadedServerNames() []string {
|
||||
// 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 {
|
||||
// Ensure MCP tools are loaded before rebuilding (SetModel may be called
|
||||
// before the first LLM call).
|
||||
a.ensureMCPTools()
|
||||
|
||||
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 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),
|
||||
))
|
||||
}
|
||||
|
||||
// Pass provider-specific options (e.g. OpenAI Responses API reasoning settings).
|
||||
if providerResult.ProviderOptions != nil {
|
||||
agentOpts = append(agentOpts, fantasy.WithProviderOptions(providerResult.ProviderOptions))
|
||||
}
|
||||
|
||||
// Pass generation parameters when available.
|
||||
// Skip max_output_tokens for providers that don't support it (e.g., Codex OAuth)
|
||||
if config.MaxTokens > 0 && !providerResult.SkipMaxOutputTokens {
|
||||
agentOpts = append(agentOpts, fantasy.WithMaxOutputTokens(int64(config.MaxTokens)))
|
||||
}
|
||||
if config.Temperature != nil {
|
||||
agentOpts = append(agentOpts, fantasy.WithTemperature(float64(*config.Temperature)))
|
||||
}
|
||||
if config.TopP != nil {
|
||||
agentOpts = append(agentOpts, fantasy.WithTopP(float64(*config.TopP)))
|
||||
}
|
||||
if config.TopK != nil {
|
||||
agentOpts = append(agentOpts, fantasy.WithTopK(int64(*config.TopK)))
|
||||
}
|
||||
|
||||
newFantasyAgent := fantasy.NewAgent(providerResult.Model, agentOpts...)
|
||||
|
||||
// Close old provider.
|
||||
if a.providerCloser != nil {
|
||||
_ = a.providerCloser.Close()
|
||||
@@ -767,9 +812,11 @@ func (a *Agent) SetModel(ctx context.Context, config *models.ProviderConfig) err
|
||||
}
|
||||
|
||||
// Swap fields.
|
||||
a.fantasyAgent = newFantasyAgent
|
||||
a.model = providerResult.Model
|
||||
a.providerCloser = providerResult.Closer
|
||||
a.providerOptions = providerResult.ProviderOptions
|
||||
a.skipMaxOutputTokens = providerResult.SkipMaxOutputTokens
|
||||
a.modelConfig = config
|
||||
|
||||
// Update provider type.
|
||||
if config.ModelString != "" {
|
||||
@@ -778,6 +825,9 @@ func (a *Agent) SetModel(ctx context.Context, config *models.ProviderConfig) err
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild the fantasy agent with the new model and current tool set.
|
||||
a.rebuildFantasyAgent()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -787,7 +837,13 @@ func (a *Agent) GetModel() fantasy.LanguageModel {
|
||||
}
|
||||
|
||||
// Close closes the agent and cleans up resources.
|
||||
// If MCP tools are still loading in the background, Close waits for them
|
||||
// to finish before closing connections to avoid resource leaks.
|
||||
func (a *Agent) Close() error {
|
||||
// Wait for background MCP loading to finish before closing connections.
|
||||
if a.mcpReady != nil {
|
||||
<-a.mcpReady
|
||||
}
|
||||
var toolErr error
|
||||
if a.toolManager != nil {
|
||||
toolErr = a.toolManager.Close()
|
||||
|
||||
@@ -997,6 +997,19 @@ func (a *App) NotifyWidgetUpdate() {
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyContentReload sends a ContentReloadEvent to the TUI so it refreshes
|
||||
// prompt templates and skills from their provider callbacks. Called by file
|
||||
// watchers when .md/.txt files change in prompt or skill directories.
|
||||
// In non-interactive mode this is a no-op.
|
||||
func (a *App) NotifyContentReload() {
|
||||
a.mu.Lock()
|
||||
prog := a.program
|
||||
a.mu.Unlock()
|
||||
if prog != nil {
|
||||
prog.Send(ContentReloadEvent{})
|
||||
}
|
||||
}
|
||||
|
||||
// SendEvent sends a tea.Msg to the registered program. Safe to call from
|
||||
// any goroutine. No-op when no program is registered.
|
||||
//
|
||||
|
||||
@@ -167,6 +167,11 @@ type ModelChangedEvent struct {
|
||||
// from its WidgetProvider on the next render cycle.
|
||||
type WidgetUpdateEvent struct{}
|
||||
|
||||
// ContentReloadEvent is sent when prompt templates or skills are reloaded
|
||||
// from disk (e.g. by a file watcher detecting changes). The TUI refreshes
|
||||
// its autocomplete entries and internal state from the provider callbacks.
|
||||
type ContentReloadEvent struct{}
|
||||
|
||||
// EditorTextSetEvent is sent when an extension calls ctx.SetEditorText to
|
||||
// pre-fill the input editor with text. The TUI handles this by setting the
|
||||
// textarea content and moving the cursor to the end.
|
||||
|
||||
@@ -199,11 +199,13 @@ type Config struct {
|
||||
Stream *bool `json:"stream,omitempty" yaml:"stream,omitempty"`
|
||||
Theme any `json:"theme" yaml:"theme"`
|
||||
// Model generation parameters
|
||||
MaxTokens int `json:"max-tokens,omitempty" yaml:"max-tokens,omitempty"`
|
||||
Temperature *float32 `json:"temperature,omitempty" yaml:"temperature,omitempty"`
|
||||
TopP *float32 `json:"top-p,omitempty" yaml:"top-p,omitempty"`
|
||||
TopK *int32 `json:"top-k,omitempty" yaml:"top-k,omitempty"`
|
||||
StopSequences []string `json:"stop-sequences,omitempty" yaml:"stop-sequences,omitempty"`
|
||||
MaxTokens int `json:"max-tokens,omitempty" yaml:"max-tokens,omitempty"`
|
||||
Temperature *float32 `json:"temperature,omitempty" yaml:"temperature,omitempty"`
|
||||
TopP *float32 `json:"top-p,omitempty" yaml:"top-p,omitempty"`
|
||||
TopK *int32 `json:"top-k,omitempty" yaml:"top-k,omitempty"`
|
||||
FrequencyPenalty *float32 `json:"frequency-penalty,omitempty" yaml:"frequency-penalty,omitempty"`
|
||||
PresencePenalty *float32 `json:"presence-penalty,omitempty" yaml:"presence-penalty,omitempty"`
|
||||
StopSequences []string `json:"stop-sequences,omitempty" yaml:"stop-sequences,omitempty"`
|
||||
|
||||
// Thinking / extended reasoning
|
||||
ThinkingLevel string `json:"thinking-level,omitempty" yaml:"thinking-level,omitempty"`
|
||||
@@ -370,6 +372,8 @@ mcpServers:
|
||||
# temperature: 0.7 # Randomness (0.0-1.0)
|
||||
# top-p: 0.95 # Nucleus sampling (0.0-1.0)
|
||||
# top-k: 40 # Top K sampling
|
||||
# frequency-penalty: 0.0 # Penalize frequent tokens (0.0-2.0)
|
||||
# presence-penalty: 0.0 # Penalize present tokens (0.0-2.0)
|
||||
# stop-sequences: ["Human:", "Assistant:"] # Custom stop sequences
|
||||
|
||||
# API Configuration (can also use environment variables)
|
||||
|
||||
+17
-13
@@ -84,23 +84,27 @@ func BuildProviderConfig() (*models.ProviderConfig, string, error) {
|
||||
temperature := float32(viper.GetFloat64("temperature"))
|
||||
topP := float32(viper.GetFloat64("top-p"))
|
||||
topK := int32(viper.GetInt("top-k"))
|
||||
frequencyPenalty := float32(viper.GetFloat64("frequency-penalty"))
|
||||
presencePenalty := float32(viper.GetFloat64("presence-penalty"))
|
||||
numGPU := int32(viper.GetInt("num-gpu-layers"))
|
||||
mainGPU := int32(viper.GetInt("main-gpu"))
|
||||
|
||||
cfg := &models.ProviderConfig{
|
||||
ModelString: viper.GetString("model"),
|
||||
SystemPrompt: systemPrompt,
|
||||
ProviderAPIKey: viper.GetString("provider-api-key"),
|
||||
ProviderURL: viper.GetString("provider-url"),
|
||||
MaxTokens: viper.GetInt("max-tokens"),
|
||||
Temperature: &temperature,
|
||||
TopP: &topP,
|
||||
TopK: &topK,
|
||||
StopSequences: viper.GetStringSlice("stop-sequences"),
|
||||
NumGPU: &numGPU,
|
||||
MainGPU: &mainGPU,
|
||||
TLSSkipVerify: viper.GetBool("tls-skip-verify"),
|
||||
ThinkingLevel: models.ParseThinkingLevel(viper.GetString("thinking-level")),
|
||||
ModelString: viper.GetString("model"),
|
||||
SystemPrompt: systemPrompt,
|
||||
ProviderAPIKey: viper.GetString("provider-api-key"),
|
||||
ProviderURL: viper.GetString("provider-url"),
|
||||
MaxTokens: viper.GetInt("max-tokens"),
|
||||
Temperature: &temperature,
|
||||
TopP: &topP,
|
||||
TopK: &topK,
|
||||
FrequencyPenalty: &frequencyPenalty,
|
||||
PresencePenalty: &presencePenalty,
|
||||
StopSequences: viper.GetStringSlice("stop-sequences"),
|
||||
NumGPU: &numGPU,
|
||||
MainGPU: &mainGPU,
|
||||
TLSSkipVerify: viper.GetBool("tls-skip-verify"),
|
||||
ThinkingLevel: models.ParseThinkingLevel(viper.GetString("thinking-level")),
|
||||
}
|
||||
|
||||
return cfg, systemPrompt, nil
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -143,20 +143,22 @@ func ParseThinkingLevel(s string) ThinkingLevel {
|
||||
|
||||
// ProviderConfig holds configuration for creating LLM providers.
|
||||
type ProviderConfig struct {
|
||||
ModelString string
|
||||
SystemPrompt string
|
||||
ProviderAPIKey string
|
||||
ProviderURL string
|
||||
MaxTokens int
|
||||
Temperature *float32
|
||||
TopP *float32
|
||||
TopK *int32
|
||||
StopSequences []string
|
||||
NumGPU *int32
|
||||
MainGPU *int32
|
||||
TLSSkipVerify bool
|
||||
ThinkingLevel ThinkingLevel
|
||||
DisableCaching bool // Opt-out: set to true to disable automatic prompt caching
|
||||
ModelString string
|
||||
SystemPrompt string
|
||||
ProviderAPIKey string
|
||||
ProviderURL string
|
||||
MaxTokens int
|
||||
Temperature *float32
|
||||
TopP *float32
|
||||
TopK *int32
|
||||
FrequencyPenalty *float32
|
||||
PresencePenalty *float32
|
||||
StopSequences []string
|
||||
NumGPU *int32
|
||||
MainGPU *int32
|
||||
TLSSkipVerify bool
|
||||
ThinkingLevel ThinkingLevel
|
||||
DisableCaching bool // Opt-out: set to true to disable automatic prompt caching
|
||||
}
|
||||
|
||||
// ProviderResult contains the result of provider creation.
|
||||
@@ -1164,6 +1166,12 @@ func buildOllamaOptions(config *ProviderConfig) map[string]any {
|
||||
if config.TopK != nil {
|
||||
options["top_k"] = int(*config.TopK)
|
||||
}
|
||||
if config.FrequencyPenalty != nil {
|
||||
options["frequency_penalty"] = *config.FrequencyPenalty
|
||||
}
|
||||
if config.PresencePenalty != nil {
|
||||
options["presence_penalty"] = *config.PresencePenalty
|
||||
}
|
||||
if len(config.StopSequences) > 0 {
|
||||
options["stop"] = config.StopSequences
|
||||
}
|
||||
|
||||
+55
-15
@@ -4,8 +4,10 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"github.com/mark3labs/kit/internal/config"
|
||||
@@ -21,6 +23,7 @@ type MCPToolManager struct {
|
||||
connectionPool *MCPConnectionPool
|
||||
tools []fantasy.AgentTool
|
||||
toolMap map[string]*toolMapping // maps prefixed tool names to their server and original name
|
||||
mu sync.Mutex // protects tools and toolMap during parallel loading
|
||||
model fantasy.LanguageModel // LLM model for sampling
|
||||
authHandler MCPAuthHandler // OAuth handler for remote servers (nil = no OAuth)
|
||||
config *config.Config
|
||||
@@ -78,35 +81,62 @@ func (m *MCPToolManager) SetDebugLogger(logger DebugLogger) {
|
||||
// Tools from different servers are prefixed with the server name to avoid naming conflicts.
|
||||
// Returns an error only if all configured servers fail to load; partial failures are logged as warnings.
|
||||
// This method is thread-safe and idempotent.
|
||||
func (m *MCPToolManager) LoadTools(ctx context.Context, config *config.Config) error {
|
||||
func (m *MCPToolManager) LoadTools(ctx context.Context, cfg *config.Config) error {
|
||||
// Initialize connection pool
|
||||
m.config = config
|
||||
m.debug = config.Debug
|
||||
m.config = cfg
|
||||
m.debug = cfg.Debug
|
||||
if m.debugLogger == nil {
|
||||
m.debugLogger = NewSimpleDebugLogger(config.Debug)
|
||||
m.debugLogger = NewSimpleDebugLogger(cfg.Debug)
|
||||
}
|
||||
m.connectionPool = NewMCPConnectionPool(DefaultConnectionPoolConfig(), m.model, config.Debug, m.authHandler)
|
||||
m.connectionPool = NewMCPConnectionPool(DefaultConnectionPoolConfig(), m.model, cfg.Debug, m.authHandler)
|
||||
m.connectionPool.SetDebugLogger(m.debugLogger)
|
||||
|
||||
var loadErrors []string
|
||||
// Load all servers in parallel. Each server connection (subprocess
|
||||
// spawn, MCP initialize handshake, ListTools) is independent and
|
||||
// typically dominated by process startup latency. Running them
|
||||
// concurrently reduces total wall-clock time from O(n * avg) to
|
||||
// O(max).
|
||||
type serverResult struct {
|
||||
name string
|
||||
err error
|
||||
}
|
||||
|
||||
for serverName, serverConfig := range config.MCPServers {
|
||||
if err := m.loadServerTools(ctx, serverName, serverConfig); err != nil {
|
||||
loadErrors = append(loadErrors, fmt.Sprintf("server %s: %v", serverName, err))
|
||||
fmt.Printf("Warning: Failed to load MCP server '%s': %v\n", serverName, err)
|
||||
continue
|
||||
results := make(chan serverResult, len(cfg.MCPServers))
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for serverName, serverConfig := range cfg.MCPServers {
|
||||
wg.Add(1)
|
||||
go func(name string, sc config.MCPServerConfig) {
|
||||
defer wg.Done()
|
||||
err := m.loadServerTools(ctx, name, sc)
|
||||
results <- serverResult{name: name, err: err}
|
||||
}(serverName, serverConfig)
|
||||
}
|
||||
|
||||
// Close results channel once all goroutines finish.
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(results)
|
||||
}()
|
||||
|
||||
var loadErrors []string
|
||||
for r := range results {
|
||||
if r.err != nil {
|
||||
loadErrors = append(loadErrors, fmt.Sprintf("server %s: %v", r.name, r.err))
|
||||
fmt.Printf("Warning: Failed to load MCP server '%s': %v\n", r.name, r.err)
|
||||
}
|
||||
}
|
||||
|
||||
// If all servers failed to load, return an error
|
||||
if len(loadErrors) == len(config.MCPServers) && len(config.MCPServers) > 0 {
|
||||
if len(loadErrors) == len(cfg.MCPServers) && len(cfg.MCPServers) > 0 {
|
||||
return fmt.Errorf("all MCP servers failed to load: %s", strings.Join(loadErrors, "; "))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadServerTools loads tools from a single MCP server
|
||||
// loadServerTools loads tools from a single MCP server.
|
||||
// Thread-safe: may be called concurrently for different servers.
|
||||
func (m *MCPToolManager) loadServerTools(ctx context.Context, serverName string, serverConfig config.MCPServerConfig) error {
|
||||
// Add debug logging
|
||||
m.debugLogConnectionInfo(serverName, serverConfig)
|
||||
@@ -134,6 +164,10 @@ func (m *MCPToolManager) loadServerTools(ctx context.Context, serverName string,
|
||||
}
|
||||
}
|
||||
|
||||
// Build tools locally before acquiring the lock.
|
||||
var localTools []fantasy.AgentTool
|
||||
localMap := make(map[string]*toolMapping)
|
||||
|
||||
// Convert MCP tools to fantasy AgentTools with prefixed names
|
||||
for _, mcpTool := range listResults.Tools {
|
||||
// Filter tools based on allowedTools/excludedTools
|
||||
@@ -193,7 +227,7 @@ func (m *MCPToolManager) loadServerTools(ctx context.Context, serverName string,
|
||||
serverConfig: serverConfig,
|
||||
manager: m,
|
||||
}
|
||||
m.toolMap[prefixedName] = mapping
|
||||
localMap[prefixedName] = mapping
|
||||
|
||||
// Create fantasy AgentTool
|
||||
fantasyTool := &mcpFantasyTool{
|
||||
@@ -206,9 +240,15 @@ func (m *MCPToolManager) loadServerTools(ctx context.Context, serverName string,
|
||||
mapping: mapping,
|
||||
}
|
||||
|
||||
m.tools = append(m.tools, fantasyTool)
|
||||
localTools = append(localTools, fantasyTool)
|
||||
}
|
||||
|
||||
// Merge into the manager under the lock.
|
||||
m.mu.Lock()
|
||||
maps.Copy(m.toolMap, localMap)
|
||||
m.tools = append(m.tools, localTools...)
|
||||
m.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
+102
-5
@@ -294,6 +294,11 @@ type AppModelOptions struct {
|
||||
// and are expanded when submitted (e.g., /review → full prompt text).
|
||||
PromptTemplates []*prompts.PromptTemplate
|
||||
|
||||
// GetPromptTemplates, if non-nil, returns the current prompt templates.
|
||||
// Called on ContentReloadEvent to refresh the template list after a file
|
||||
// watcher detects changes. May be nil if prompt hot-reload is not needed.
|
||||
GetPromptTemplates func() []*prompts.PromptTemplate
|
||||
|
||||
// ContextPaths lists absolute paths of loaded context files (e.g.
|
||||
// AGENTS.md). Displayed in the [Context] startup section.
|
||||
ContextPaths []string
|
||||
@@ -301,6 +306,11 @@ type AppModelOptions struct {
|
||||
// SkillItems lists loaded skills for the [Skills] startup section.
|
||||
SkillItems []SkillItem
|
||||
|
||||
// GetSkillItems, if non-nil, returns the current skill items.
|
||||
// Called on ContentReloadEvent to refresh the skill list after a file
|
||||
// watcher detects changes. May be nil if skill hot-reload is not needed.
|
||||
GetSkillItems func() []SkillItem
|
||||
|
||||
// MCPToolCount is the number of tools loaded from external MCP servers.
|
||||
MCPToolCount int
|
||||
|
||||
@@ -500,6 +510,10 @@ type AppModel struct {
|
||||
// They appear in autocomplete and are expanded when submitted.
|
||||
promptTemplates []*prompts.PromptTemplate
|
||||
|
||||
// getPromptTemplates returns the current prompt templates. Used to
|
||||
// refresh the template list after content hot-reload. May be nil.
|
||||
getPromptTemplates func() []*prompts.PromptTemplate
|
||||
|
||||
// treeSelector is the tree navigation overlay, active in stateTreeSelector.
|
||||
treeSelector *TreeSelectorComponent
|
||||
|
||||
@@ -508,6 +522,10 @@ type AppModel struct {
|
||||
contextPaths []string
|
||||
skillItems []SkillItem
|
||||
|
||||
// getSkillItems returns the current skill items. Used to refresh the
|
||||
// skill list after content hot-reload. May be nil.
|
||||
getSkillItems func() []SkillItem
|
||||
|
||||
// mcpToolCount and extensionToolCount track tool counts by source for
|
||||
// the startup info display.
|
||||
mcpToolCount int
|
||||
@@ -721,6 +739,7 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel {
|
||||
// Store extension commands for dispatch.
|
||||
m.extensionCommands = opts.ExtensionCommands
|
||||
m.promptTemplates = opts.PromptTemplates
|
||||
m.getPromptTemplates = opts.GetPromptTemplates
|
||||
m.getWidgets = opts.GetWidgets
|
||||
m.getHeader = opts.GetHeader
|
||||
m.getFooter = opts.GetFooter
|
||||
@@ -746,6 +765,7 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel {
|
||||
// Store context/skills metadata and tool counts for startup display.
|
||||
m.contextPaths = opts.ContextPaths
|
||||
m.skillItems = opts.SkillItems
|
||||
m.getSkillItems = opts.GetSkillItems
|
||||
m.mcpToolCount = opts.MCPToolCount
|
||||
m.extensionToolCount = opts.ExtensionToolCount
|
||||
m.startupExtensionMessages = opts.StartupExtensionMessages
|
||||
@@ -1700,6 +1720,13 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.stream, _ = updated.(streamComponentIface)
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
// Mark any trailing StreamingMessageItem as complete so its live
|
||||
// timer freezes and it is not left in a dangling streaming state.
|
||||
if len(m.messages) > 0 {
|
||||
if streamMsg, ok := m.messages[len(m.messages)-1].(*StreamingMessageItem); ok {
|
||||
streamMsg.MarkComplete()
|
||||
}
|
||||
}
|
||||
m.state = stateInput
|
||||
m.canceling = false
|
||||
|
||||
@@ -1711,6 +1738,12 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.stream, _ = updated.(streamComponentIface)
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
// Mark any trailing StreamingMessageItem as complete (see StepCompleteEvent).
|
||||
if len(m.messages) > 0 {
|
||||
if streamMsg, ok := m.messages[len(m.messages)-1].(*StreamingMessageItem); ok {
|
||||
streamMsg.MarkComplete()
|
||||
}
|
||||
}
|
||||
m.state = stateInput
|
||||
m.canceling = false
|
||||
|
||||
@@ -1723,6 +1756,12 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.stream, _ = updated.(streamComponentIface)
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
// Mark any trailing StreamingMessageItem as complete (see StepCompleteEvent).
|
||||
if len(m.messages) > 0 {
|
||||
if streamMsg, ok := m.messages[len(m.messages)-1].(*StreamingMessageItem); ok {
|
||||
streamMsg.MarkComplete()
|
||||
}
|
||||
}
|
||||
if msg.Err != nil {
|
||||
m.printErrorResponse(msg)
|
||||
}
|
||||
@@ -1798,6 +1837,12 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
}
|
||||
|
||||
case app.ContentReloadEvent:
|
||||
// Prompt templates or skills changed on disk — refresh from providers.
|
||||
m.refreshPromptTemplates()
|
||||
m.refreshSkillItems()
|
||||
m.printSystemMessage("Prompts and skills reloaded.")
|
||||
|
||||
case app.EditorTextSetEvent:
|
||||
// Extension wants to pre-fill the input editor with text.
|
||||
if ic, ok := m.input.(*InputComponent); ok {
|
||||
@@ -2691,6 +2736,43 @@ func (m *AppModel) expandPromptTemplate(text string) (string, bool) {
|
||||
return text, false
|
||||
}
|
||||
|
||||
// refreshPromptTemplates reloads prompt templates from the provider callback
|
||||
// and updates the autocomplete entries. Called on ContentReloadEvent.
|
||||
func (m *AppModel) refreshPromptTemplates() {
|
||||
if m.getPromptTemplates == nil {
|
||||
return
|
||||
}
|
||||
newTemplates := m.getPromptTemplates()
|
||||
m.promptTemplates = newTemplates
|
||||
|
||||
if ic, ok := m.input.(*InputComponent); ok {
|
||||
// Remove old prompt commands and add fresh ones.
|
||||
var kept []commands.SlashCommand
|
||||
for _, sc := range ic.commands {
|
||||
if sc.Category != "Prompts" {
|
||||
kept = append(kept, sc)
|
||||
}
|
||||
}
|
||||
for _, tpl := range newTemplates {
|
||||
kept = append(kept, commands.SlashCommand{
|
||||
Name: "/" + tpl.Name,
|
||||
Description: tpl.Description,
|
||||
Category: "Prompts",
|
||||
})
|
||||
}
|
||||
ic.commands = kept
|
||||
}
|
||||
}
|
||||
|
||||
// refreshSkillItems reloads skill items from the provider callback.
|
||||
// Called on ContentReloadEvent.
|
||||
func (m *AppModel) refreshSkillItems() {
|
||||
if m.getSkillItems == nil {
|
||||
return
|
||||
}
|
||||
m.skillItems = m.getSkillItems()
|
||||
}
|
||||
|
||||
// printHelpMessage renders the help text listing all available slash commands.
|
||||
func (m *AppModel) printHelpMessage() {
|
||||
help := "## Available Commands\n\n" +
|
||||
@@ -2886,12 +2968,27 @@ func (m *AppModel) flushStreamAndPendingUserMessages() {
|
||||
if content := m.stream.GetRenderedContent(); content != "" {
|
||||
m.stream.Reset()
|
||||
|
||||
// Render styled content using MessageRenderer
|
||||
styledMsg := m.renderer.RenderAssistantMessage(content, time.Now(), m.modelName)
|
||||
// Check whether the content is already in the ScrollList as a
|
||||
// StreamingMessageItem (created by appendStreamingChunk during
|
||||
// ReasoningChunkEvent / StreamChunkEvent). If so, just mark it
|
||||
// complete — creating a second StyledMessageItem would duplicate
|
||||
// the rendered block and shift mouse hit-testing coordinates.
|
||||
alreadyInList := false
|
||||
if len(m.messages) > 0 {
|
||||
if streamMsg, ok := m.messages[len(m.messages)-1].(*StreamingMessageItem); ok {
|
||||
streamMsg.MarkComplete()
|
||||
alreadyInList = true
|
||||
}
|
||||
}
|
||||
|
||||
// Add to in-memory scrollList with styled content
|
||||
msg := NewStyledMessageItem(generateMessageID(), "assistant", content, styledMsg.Content)
|
||||
m.messages = append(m.messages, msg)
|
||||
if !alreadyInList {
|
||||
// Render styled content using MessageRenderer
|
||||
styledMsg := m.renderer.RenderAssistantMessage(content, time.Now(), m.modelName)
|
||||
|
||||
// Add to in-memory scrollList with styled content
|
||||
msg := NewStyledMessageItem(generateMessageID(), "assistant", content, styledMsg.Content)
|
||||
m.messages = append(m.messages, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
// Package watcher provides a general-purpose file watcher that monitors
|
||||
// directories for changes to files matching specified extensions. It uses
|
||||
// fsnotify for kernel-level notifications with debouncing to coalesce
|
||||
// rapid editor writes.
|
||||
package watcher
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/fsnotify/fsnotify"
|
||||
)
|
||||
|
||||
// ContentWatcher monitors directories for file changes matching a set of
|
||||
// extensions and triggers a reload callback when changes are detected.
|
||||
// It uses fsnotify for kernel-level file notifications (inotify on Linux,
|
||||
// kqueue on macOS) with debouncing to coalesce rapid editor writes.
|
||||
type ContentWatcher struct {
|
||||
watcher *fsnotify.Watcher
|
||||
onReload func()
|
||||
extensions []string // e.g. [".md", ".txt"]
|
||||
label string // for logging (e.g. "prompts", "skills")
|
||||
debounce time.Duration
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// Options configures a ContentWatcher.
|
||||
type Options struct {
|
||||
// Dirs are the directories to watch.
|
||||
Dirs []string
|
||||
// Extensions are the file extensions to watch for (e.g. ".md", ".txt").
|
||||
// Include the leading dot.
|
||||
Extensions []string
|
||||
// OnReload is called when a matching file changes (after debouncing).
|
||||
OnReload func()
|
||||
// Label is a human-readable name for logging (e.g. "prompts", "skills").
|
||||
Label string
|
||||
// Debounce is the debounce duration. Defaults to 300ms if zero.
|
||||
Debounce time.Duration
|
||||
}
|
||||
|
||||
// New creates a ContentWatcher that monitors the given directories for
|
||||
// file changes matching the specified extensions. When a change is detected
|
||||
// (after debouncing), onReload is called. The watcher must be started with
|
||||
// Start() and stopped with Close().
|
||||
func New(opts Options) (*ContentWatcher, error) {
|
||||
if len(opts.Dirs) == 0 {
|
||||
return nil, fmt.Errorf("no directories to watch")
|
||||
}
|
||||
|
||||
fsw, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating file watcher: %w", err)
|
||||
}
|
||||
|
||||
for _, dir := range opts.Dirs {
|
||||
if err := fsw.Add(dir); err != nil {
|
||||
log.Debug("watcher: skipping directory", "label", opts.Label, "dir", dir, "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Also watch immediate subdirectories (for skill/SKILL.md pattern).
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
subdir := filepath.Join(dir, entry.Name())
|
||||
if err := fsw.Add(subdir); err != nil {
|
||||
log.Debug("watcher: skipping subdirectory", "label", opts.Label, "dir", subdir, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debounce := opts.Debounce
|
||||
if debounce == 0 {
|
||||
debounce = 300 * time.Millisecond
|
||||
}
|
||||
|
||||
return &ContentWatcher{
|
||||
watcher: fsw,
|
||||
onReload: opts.OnReload,
|
||||
extensions: opts.Extensions,
|
||||
label: opts.Label,
|
||||
debounce: debounce,
|
||||
done: make(chan struct{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Start begins watching for file changes. It blocks until the context
|
||||
// is cancelled or Close() is called. Typically called in a goroutine.
|
||||
func (w *ContentWatcher) Start(ctx context.Context) {
|
||||
w.mu.Lock()
|
||||
ctx, w.cancel = context.WithCancel(ctx)
|
||||
w.mu.Unlock()
|
||||
|
||||
defer close(w.done)
|
||||
|
||||
var timer *time.Timer
|
||||
var timerC <-chan time.Time
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
return
|
||||
|
||||
case event, ok := <-w.watcher.Events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Only care about files matching our extensions.
|
||||
if !w.matchesExtension(event.Name) {
|
||||
continue
|
||||
}
|
||||
|
||||
// React to write, create, remove, rename events.
|
||||
if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Remove|fsnotify.Rename) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
log.Debug("watcher: file changed", "label", w.label, "file", event.Name, "op", event.Op)
|
||||
|
||||
// Debounce: reset timer on each event.
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
timer = time.NewTimer(w.debounce)
|
||||
timerC = timer.C
|
||||
|
||||
case <-timerC:
|
||||
timerC = nil
|
||||
timer = nil
|
||||
log.Debug("watcher: reloading", "label", w.label)
|
||||
w.onReload()
|
||||
|
||||
case err, ok := <-w.watcher.Errors:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log.Warn("watcher: error", "label", w.label, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close stops the watcher and releases resources.
|
||||
func (w *ContentWatcher) Close() error {
|
||||
w.mu.Lock()
|
||||
cancel := w.cancel
|
||||
w.mu.Unlock()
|
||||
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
|
||||
// Wait for the event loop to finish.
|
||||
<-w.done
|
||||
return w.watcher.Close()
|
||||
}
|
||||
|
||||
// matchesExtension returns true if the file name ends with one of the
|
||||
// watched extensions.
|
||||
func (w *ContentWatcher) matchesExtension(name string) bool {
|
||||
for _, ext := range w.extensions {
|
||||
if strings.HasSuffix(name, ext) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CollectDirs returns the directories to watch for a given set of standard
|
||||
// directories and extra paths. Directories are deduplicated by absolute path
|
||||
// and verified to exist. For explicit file paths, the parent directory is
|
||||
// watched instead.
|
||||
func CollectDirs(standardDirs []string, extraPaths []string) []string {
|
||||
var dirs []string
|
||||
seen := make(map[string]bool)
|
||||
|
||||
add := func(dir string) {
|
||||
abs, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if seen[abs] {
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the directory exists.
|
||||
info, err := os.Stat(abs)
|
||||
if err != nil || !info.IsDir() {
|
||||
return
|
||||
}
|
||||
|
||||
seen[abs] = true
|
||||
dirs = append(dirs, abs)
|
||||
}
|
||||
|
||||
for _, d := range standardDirs {
|
||||
add(d)
|
||||
}
|
||||
|
||||
for _, p := range extraPaths {
|
||||
info, err := os.Stat(p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if info.IsDir() {
|
||||
add(p)
|
||||
} else {
|
||||
// For explicit files, watch the parent directory.
|
||||
add(filepath.Dir(p))
|
||||
}
|
||||
}
|
||||
|
||||
return dirs
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package watcher
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestContentWatcher_ReloadsOnMatchingFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Write an initial file so the directory isn't empty.
|
||||
initial := filepath.Join(dir, "existing.md")
|
||||
if err := os.WriteFile(initial, []byte("# Hello"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var reloadCount atomic.Int32
|
||||
w, err := New(Options{
|
||||
Dirs: []string{dir},
|
||||
Extensions: []string{".md"},
|
||||
OnReload: func() { reloadCount.Add(1) },
|
||||
Label: "test",
|
||||
Debounce: 50 * time.Millisecond,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
go w.Start(t.Context())
|
||||
|
||||
// Wait for watcher to be ready.
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Modify the file.
|
||||
if err := os.WriteFile(initial, []byte("# Updated"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Wait for debounce + processing.
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
if got := reloadCount.Load(); got != 1 {
|
||||
t.Errorf("expected 1 reload, got %d", got)
|
||||
}
|
||||
|
||||
_ = w.Close()
|
||||
}
|
||||
|
||||
func TestContentWatcher_IgnoresNonMatchingFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
var reloadCount atomic.Int32
|
||||
w, err := New(Options{
|
||||
Dirs: []string{dir},
|
||||
Extensions: []string{".md"},
|
||||
OnReload: func() { reloadCount.Add(1) },
|
||||
Label: "test",
|
||||
Debounce: 50 * time.Millisecond,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
go w.Start(t.Context())
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Write a non-matching file.
|
||||
if err := os.WriteFile(filepath.Join(dir, "readme.txt"), []byte("hello"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
if got := reloadCount.Load(); got != 0 {
|
||||
t.Errorf("expected 0 reloads for non-matching file, got %d", got)
|
||||
}
|
||||
|
||||
_ = w.Close()
|
||||
}
|
||||
|
||||
func TestContentWatcher_MultipleExtensions(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
var reloadCount atomic.Int32
|
||||
w, err := New(Options{
|
||||
Dirs: []string{dir},
|
||||
Extensions: []string{".md", ".txt"},
|
||||
OnReload: func() { reloadCount.Add(1) },
|
||||
Label: "test",
|
||||
Debounce: 50 * time.Millisecond,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
go w.Start(t.Context())
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Write a .txt file — should trigger.
|
||||
if err := os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("notes"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
if got := reloadCount.Load(); got != 1 {
|
||||
t.Errorf("expected 1 reload for .txt file, got %d", got)
|
||||
}
|
||||
|
||||
_ = w.Close()
|
||||
}
|
||||
|
||||
func TestContentWatcher_Debounces(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
var reloadCount atomic.Int32
|
||||
w, err := New(Options{
|
||||
Dirs: []string{dir},
|
||||
Extensions: []string{".md"},
|
||||
OnReload: func() { reloadCount.Add(1) },
|
||||
Label: "test",
|
||||
Debounce: 100 * time.Millisecond,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
go w.Start(t.Context())
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Rapid-fire writes — should debounce into 1 reload.
|
||||
for i := range 5 {
|
||||
if err := os.WriteFile(filepath.Join(dir, "test.md"), []byte("v"+string(rune('0'+i))), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
}
|
||||
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
if got := reloadCount.Load(); got != 1 {
|
||||
t.Errorf("expected 1 debounced reload, got %d", got)
|
||||
}
|
||||
|
||||
_ = w.Close()
|
||||
}
|
||||
|
||||
func TestContentWatcher_WatchesSubdirectories(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Create a subdirectory (simulates skill-name/SKILL.md pattern).
|
||||
subdir := filepath.Join(dir, "my-skill")
|
||||
if err := os.MkdirAll(subdir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var reloadCount atomic.Int32
|
||||
w, err := New(Options{
|
||||
Dirs: []string{dir},
|
||||
Extensions: []string{".md"},
|
||||
OnReload: func() { reloadCount.Add(1) },
|
||||
Label: "test",
|
||||
Debounce: 50 * time.Millisecond,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
go w.Start(t.Context())
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Write to subdirectory.
|
||||
if err := os.WriteFile(filepath.Join(subdir, "SKILL.md"), []byte("# Skill"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
if got := reloadCount.Load(); got != 1 {
|
||||
t.Errorf("expected 1 reload for subdirectory file, got %d", got)
|
||||
}
|
||||
|
||||
_ = w.Close()
|
||||
}
|
||||
|
||||
func TestCollectDirs_Deduplicates(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
dirs := CollectDirs([]string{dir, dir}, nil)
|
||||
if len(dirs) != 1 {
|
||||
t.Errorf("expected 1 deduplicated dir, got %d", len(dirs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectDirs_FileParent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
file := filepath.Join(dir, "test.md")
|
||||
if err := os.WriteFile(file, []byte("test"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dirs := CollectDirs(nil, []string{file})
|
||||
if len(dirs) != 1 {
|
||||
t.Fatalf("expected 1 dir, got %d", len(dirs))
|
||||
}
|
||||
|
||||
abs, _ := filepath.Abs(dir)
|
||||
if dirs[0] != abs {
|
||||
t.Errorf("expected %s, got %s", abs, dirs[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectDirs_SkipsNonexistent(t *testing.T) {
|
||||
dirs := CollectDirs([]string{"/nonexistent/dir"}, nil)
|
||||
if len(dirs) != 0 {
|
||||
t.Errorf("expected 0 dirs for nonexistent path, got %d", len(dirs))
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,8 @@ func setSDKDefaults() {
|
||||
viper.SetDefault("temperature", 0.7)
|
||||
viper.SetDefault("top-p", 0.95)
|
||||
viper.SetDefault("top-k", 40)
|
||||
viper.SetDefault("frequency-penalty", 0.0)
|
||||
viper.SetDefault("presence-penalty", 0.0)
|
||||
viper.SetDefault("stream", true)
|
||||
viper.SetDefault("thinking-level", "off")
|
||||
viper.SetDefault("num-gpu-layers", -1)
|
||||
|
||||
@@ -49,6 +49,7 @@ type Kit struct {
|
||||
extRunner *extensions.Runner
|
||||
bufferedLogger *tools.BufferedDebugLogger
|
||||
authHandler MCPAuthHandler // OAuth handler for remote MCP servers (may need Close)
|
||||
opts *Options // stored for reload operations (skills, etc.)
|
||||
|
||||
// Hook registries — interception layer (see hooks.go).
|
||||
beforeToolCall *hookRegistry[BeforeToolCallHook, BeforeToolCallResult]
|
||||
@@ -113,15 +114,32 @@ func (m *Kit) GetLoadingMessage() string {
|
||||
}
|
||||
|
||||
// GetLoadedServerNames returns the names of successfully loaded MCP servers.
|
||||
// If MCP servers are still loading in the background, this returns only the
|
||||
// servers that have completed loading so far.
|
||||
func (m *Kit) GetLoadedServerNames() []string {
|
||||
return m.agent.GetLoadedServerNames()
|
||||
}
|
||||
|
||||
// GetMCPToolCount returns the number of tools loaded from external MCP servers.
|
||||
// If MCP servers are still loading in the background, this returns the count
|
||||
// of tools loaded so far (may be 0).
|
||||
func (m *Kit) GetMCPToolCount() int {
|
||||
return m.agent.GetMCPToolCount()
|
||||
}
|
||||
|
||||
// WaitForMCPTools blocks until background MCP tool loading completes.
|
||||
// Returns nil if no MCP servers are configured or if loading succeeded.
|
||||
// Returns the loading error if all servers failed. Safe to call multiple times.
|
||||
func (m *Kit) WaitForMCPTools() error {
|
||||
return m.agent.WaitForMCPTools()
|
||||
}
|
||||
|
||||
// MCPToolsReady returns true if MCP tool loading has completed (or was never
|
||||
// started). This is a non-blocking check useful for UI status display.
|
||||
func (m *Kit) MCPToolsReady() bool {
|
||||
return m.agent.MCPToolsReady()
|
||||
}
|
||||
|
||||
// GetExtensionToolCount returns the number of tools registered by extensions.
|
||||
func (m *Kit) GetExtensionToolCount() int {
|
||||
return m.agent.GetExtensionToolCount()
|
||||
@@ -225,6 +243,10 @@ func (m *Kit) SetModel(ctx context.Context, modelString string) error {
|
||||
config.TopP = &topP
|
||||
topK := int32(viper.GetInt("top-k"))
|
||||
config.TopK = &topK
|
||||
frequencyPenalty := float32(viper.GetFloat64("frequency-penalty"))
|
||||
config.FrequencyPenalty = &frequencyPenalty
|
||||
presencePenalty := float32(viper.GetFloat64("presence-penalty"))
|
||||
config.PresencePenalty = &presencePenalty
|
||||
|
||||
if err := m.agent.SetModel(ctx, config); err != nil {
|
||||
return err
|
||||
@@ -716,6 +738,7 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
|
||||
extRunner: agentResult.ExtRunner,
|
||||
bufferedLogger: agentResult.BufferedLogger,
|
||||
authHandler: setupOpts.AuthHandler,
|
||||
opts: opts,
|
||||
beforeToolCall: beforeToolCall,
|
||||
afterToolResult: afterToolResult,
|
||||
beforeTurn: beforeTurn,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package kit
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/mark3labs/kit/internal/extensions"
|
||||
@@ -136,3 +137,15 @@ func (m *Kit) ClearSkillCache() {
|
||||
defer m.skillCache.mu.Unlock()
|
||||
m.skillCache.skills = nil
|
||||
}
|
||||
|
||||
// ReloadSkills re-discovers skills from disk, replacing the current set.
|
||||
// This is called by file watchers when skill files change.
|
||||
func (m *Kit) ReloadSkills() error {
|
||||
newSkills, err := loadSkills(m.opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reloading skills: %w", err)
|
||||
}
|
||||
m.skills = newSkills
|
||||
m.ClearSkillCache()
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user