diff --git a/.gitignore b/.gitignore index 02ef18d1f..977eb0b5b 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,4 @@ dist/ # Windows Application Icon/Resource *.syso +AGENTS.md diff --git a/go.mod b/go.mod index c1172937c..6fa3a900c 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/gdamore/tcell/v2 v2.13.8 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 + github.com/h2non/filetype v1.1.3 github.com/larksuite/oapi-sdk-go/v3 v3.5.3 github.com/mdp/qrterminal/v3 v3.2.1 github.com/modelcontextprotocol/go-sdk v1.3.0 @@ -37,8 +38,6 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect github.com/gdamore/encoding v1.0.1 // indirect - github.com/gdamore/tcell/v2 v2.13.8 // indirect - github.com/h2non/filetype v1.1.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index ed25f537f..64cf83797 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -113,7 +113,38 @@ func NewAgentInstance( summarizeTokenPercent = 75 } - // Resolve fallback candidates + candidates := ResolveCandidatesForModel(cfg, defaults.Provider, model, fallbacks) + + return &AgentInstance{ + ID: agentID, + Name: agentName, + Model: model, + Fallbacks: fallbacks, + Workspace: workspace, + MaxIterations: maxIter, + MaxTokens: maxTokens, + Temperature: temperature, + ContextWindow: maxTokens, + SummarizeMessageThreshold: summarizeMessageThreshold, + SummarizeTokenPercent: summarizeTokenPercent, + Provider: provider, + Sessions: sessionsManager, + ContextBuilder: contextBuilder, + Tools: toolsRegistry, + Subagents: subagents, + SkillsFilter: skillsFilter, + Candidates: candidates, + } +} + +// ResolveCandidatesForModel resolves fallback candidates using the same lookup +// behavior as AgentInstance construction. It is reused by model hot-switch paths. +func ResolveCandidatesForModel( + cfg *config.Config, + defaultProvider string, + model string, + fallbacks []string, +) []providers.FallbackCandidate { modelCfg := providers.ModelConfig{ Primary: model, Fallbacks: fallbacks, @@ -158,28 +189,7 @@ func NewAgentInstance( return "", false } - candidates := providers.ResolveCandidatesWithLookup(modelCfg, defaults.Provider, resolveFromModelList) - - return &AgentInstance{ - ID: agentID, - Name: agentName, - Model: model, - Fallbacks: fallbacks, - Workspace: workspace, - MaxIterations: maxIter, - MaxTokens: maxTokens, - Temperature: temperature, - ContextWindow: maxTokens, - SummarizeMessageThreshold: summarizeMessageThreshold, - SummarizeTokenPercent: summarizeTokenPercent, - Provider: provider, - Sessions: sessionsManager, - ContextBuilder: contextBuilder, - Tools: toolsRegistry, - Subagents: subagents, - SkillsFilter: skillsFilter, - Candidates: candidates, - } + return providers.ResolveCandidatesWithLookup(modelCfg, defaultProvider, resolveFromModelList) } // resolveAgentWorkspace determines the workspace directory for an agent. diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index db9efa2cf..752746279 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -37,6 +37,7 @@ type AgentLoop struct { bus *bus.MessageBus cfg *config.Config registry *AgentRegistry + modelSwitch *ModelSwitchManager state *state.Manager running atomic.Bool summarizing sync.Map @@ -66,6 +67,7 @@ func NewAgentLoop( provider providers.LLMProvider, ) *AgentLoop { registry := NewAgentRegistry(cfg, provider) + modelSwitch := NewModelSwitchManager(cfg, registry) // Register shared tools to all agents registerSharedTools(cfg, msgBus, registry, provider) @@ -85,6 +87,7 @@ func NewAgentLoop( bus: msgBus, cfg: cfg, registry: registry, + modelSwitch: modelSwitch, state: stateManager, summarizing: sync.Map{}, fallback: fallbackChain, @@ -1459,7 +1462,9 @@ func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage) return "No default agent configured", true } oldModel := defaultAgent.Model - defaultAgent.Model = value + if err := al.modelSwitch.SwitchModel("", value); err != nil { + return fmt.Sprintf("Failed to switch model: %v", err), true + } return fmt.Sprintf("Switched model from %s to %s", oldModel, value), true case "channel": if al.channelManager == nil { diff --git a/pkg/agent/model_switch.go b/pkg/agent/model_switch.go new file mode 100644 index 000000000..430e40d37 --- /dev/null +++ b/pkg/agent/model_switch.go @@ -0,0 +1,110 @@ +package agent + +import ( + "fmt" + "sync" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/providers" +) + +// ModelSwitchManager handles dynamic model switching at runtime. +type ModelSwitchManager struct { + config *config.Config + registry *AgentRegistry + mu sync.RWMutex +} + +// NewModelSwitchManager creates a new model switch manager. +func NewModelSwitchManager(cfg *config.Config, registry *AgentRegistry) *ModelSwitchManager { + return &ModelSwitchManager{ + config: cfg, + registry: registry, + } +} + +// SwitchModel switches the model for a given session. +// If sessionKey is empty, it updates the global default model. +func (m *ModelSwitchManager) SwitchModel(sessionKey, modelName string) error { + m.mu.Lock() + defer m.mu.Unlock() + + // Validate model exists in config + if _, err := m.config.GetModelConfig(modelName); err != nil { + return fmt.Errorf("model %q not found. Available models: %v", modelName, m.listAvailableModels()) + } + + // Session-scoped override is not implemented yet. + if sessionKey != "" { + return fmt.Errorf("session-scoped model switch is not supported yet") + } + + oldModel := m.config.Agents.Defaults.GetModelName() + if oldModel == modelName { + return nil + } + + if err := m.config.SetDefaultModel(modelName); err != nil { + return fmt.Errorf("failed to set default model: %w", err) + } + + newProvider, _, err := providers.CreateProvider(m.config) + if err != nil { + // Roll back config on provider creation failure. + _ = m.config.SetDefaultModel(oldModel) + return fmt.Errorf("failed to create provider for model switch: %w", err) + } + + if err := m.registry.SwitchModel(m.config, oldModel, modelName, newProvider); err != nil { + _ = m.config.SetDefaultModel(oldModel) + if cp, ok := newProvider.(providers.StatefulProvider); ok { + cp.Close() + } + return fmt.Errorf("failed to apply hot model switch: %w", err) + } + + return nil +} + +// GetCurrentModel returns the current model for a session. +// If session-specific override exists, returns that. Otherwise returns global default. +func (m *ModelSwitchManager) GetCurrentModel(sessionKey string) string { + m.mu.RLock() + defer m.mu.RUnlock() + + // For now, always return the global default model + // Session-scoped overrides can be added later + return m.config.Agents.Defaults.GetModelName() +} + +// ValidateModel validates if a model exists in the configuration. +func (m *ModelSwitchManager) ValidateModel(modelName string) (*config.ModelConfig, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + return m.config.GetModelConfig(modelName) +} + +// listAvailableModels returns a slice of all available model names. +func (m *ModelSwitchManager) listAvailableModels() []string { + m.mu.RLock() + defer m.mu.RUnlock() + + models := make([]string, 0, len(m.config.ModelList)) + for _, mc := range m.config.ModelList { + if mc.ModelName != "" { + models = append(models, mc.ModelName) + } + } + return models +} + +// GetModelInfo returns formatted information about the current model. +func (m *ModelSwitchManager) GetModelInfo(sessionKey string) (string, string) { + currentModel := m.GetCurrentModel(sessionKey) + if mc, err := m.config.GetModelConfig(currentModel); err == nil { + return currentModel, mc.Model + } + + return currentModel, "unknown" +} diff --git a/pkg/agent/model_switch_test.go b/pkg/agent/model_switch_test.go new file mode 100644 index 000000000..c2e4c7234 --- /dev/null +++ b/pkg/agent/model_switch_test.go @@ -0,0 +1,120 @@ +package agent + +import ( + "context" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/providers" +) + +type statefulMockProvider struct { + closed bool +} + +func (m *statefulMockProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + options map[string]any, +) (*providers.LLMResponse, error) { + return &providers.LLMResponse{Content: "ok"}, nil +} + +func (m *statefulMockProvider) GetDefaultModel() string { return "mock" } +func (m *statefulMockProvider) Close() { m.closed = true } + +func testSwitchConfig() *config.Config { + return &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: "/tmp/picoclaw-switch-test", + ModelName: "model-a", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + List: []config.AgentConfig{ + {ID: "main", Default: true}, + {ID: "worker", Model: &config.AgentModelConfig{Primary: "fixed-model"}}, + }, + }, + ModelList: []config.ModelConfig{ + {ModelName: "model-a", Model: "codex-cli/codex"}, + {ModelName: "model-b", Model: "claude-cli/claude"}, + {ModelName: "fixed-model", Model: "codex-cli/fixed"}, + {ModelName: "broken-model", Model: "openai/gpt-bad"}, + }, + } +} + +func TestModelSwitchManager_SwitchModelSuccess(t *testing.T) { + cfg := testSwitchConfig() + initialProvider := &statefulMockProvider{} + registry := NewAgentRegistry(cfg, initialProvider) + manager := NewModelSwitchManager(cfg, registry) + + if err := manager.SwitchModel("", "model-b"); err != nil { + t.Fatalf("SwitchModel() error = %v", err) + } + + if got := cfg.Agents.Defaults.GetModelName(); got != "model-b" { + t.Fatalf("default model = %q, want %q", got, "model-b") + } + + mainAgent, ok := registry.GetAgent("main") + if !ok { + t.Fatal("main agent not found") + } + if mainAgent.Model != "model-b" { + t.Fatalf("main agent model = %q, want %q", mainAgent.Model, "model-b") + } + if len(mainAgent.Candidates) == 0 || mainAgent.Candidates[0].Provider != "claude-cli" { + t.Fatalf("main candidates not refreshed: %+v", mainAgent.Candidates) + } + + worker, ok := registry.GetAgent("worker") + if !ok { + t.Fatal("worker agent not found") + } + if worker.Model != "fixed-model" { + t.Fatalf("worker model should remain fixed-model, got %q", worker.Model) + } + if worker.Provider == initialProvider { + t.Fatal("worker provider should be hot-swapped to new provider") + } + + if !initialProvider.closed { + t.Fatal("old stateful provider should be closed after switch") + } +} + +func TestModelSwitchManager_SwitchModelRollbackOnProviderCreateFailure(t *testing.T) { + cfg := testSwitchConfig() + initialProvider := &statefulMockProvider{} + registry := NewAgentRegistry(cfg, initialProvider) + manager := NewModelSwitchManager(cfg, registry) + + err := manager.SwitchModel("", "broken-model") + if err == nil { + t.Fatal("expected error for broken-model provider creation") + } + + if got := cfg.Agents.Defaults.GetModelName(); got != "model-a" { + t.Fatalf("default model should roll back to model-a, got %q", got) + } + + mainAgent, ok := registry.GetAgent("main") + if !ok { + t.Fatal("main agent not found") + } + if mainAgent.Model != "model-a" { + t.Fatalf("main model should remain model-a, got %q", mainAgent.Model) + } + if mainAgent.Provider != initialProvider { + t.Fatal("provider should remain unchanged on failure") + } + if initialProvider.closed { + t.Fatal("old provider should not be closed when switch fails") + } +} diff --git a/pkg/agent/registry.go b/pkg/agent/registry.go index 77b846832..c89371c20 100644 --- a/pkg/agent/registry.go +++ b/pkg/agent/registry.go @@ -1,6 +1,8 @@ package agent import ( + "fmt" + "slices" "sync" "github.com/sipeed/picoclaw/pkg/config" @@ -11,9 +13,10 @@ import ( // AgentRegistry manages multiple agent instances and routes messages to them. type AgentRegistry struct { - agents map[string]*AgentInstance - resolver *routing.RouteResolver - mu sync.RWMutex + agents map[string]*AgentInstance + resolver *routing.RouteResolver + defaultAgentID string + mu sync.RWMutex } // NewAgentRegistry creates a registry from config, instantiating all agents. @@ -34,6 +37,7 @@ func NewAgentRegistry( } instance := NewAgentInstance(implicitAgent, &cfg.Agents.Defaults, cfg, provider) registry.agents["main"] = instance + registry.defaultAgentID = "main" logger.InfoCF("agent", "Created implicit main agent (no agents.list configured)", nil) } else { for i := range agentConfigs { @@ -41,6 +45,9 @@ func NewAgentRegistry( id := routing.NormalizeAgentID(ac.ID) instance := NewAgentInstance(ac, &cfg.Agents.Defaults, cfg, provider) registry.agents[id] = instance + if ac.Default && registry.defaultAgentID == "" { + registry.defaultAgentID = id + } logger.InfoCF("agent", "Registered agent", map[string]any{ "agent_id": id, @@ -51,6 +58,17 @@ func NewAgentRegistry( } } + if registry.defaultAgentID == "" { + ids := make([]string, 0, len(registry.agents)) + for id := range registry.agents { + ids = append(ids, id) + } + slices.Sort(ids) + if len(ids) > 0 { + registry.defaultAgentID = ids[0] + } + } + return registry } @@ -104,11 +122,61 @@ func (r *AgentRegistry) CanSpawnSubagent(parentAgentID, targetAgentID string) bo func (r *AgentRegistry) GetDefaultAgent() *AgentInstance { r.mu.RLock() defer r.mu.RUnlock() + if r.defaultAgentID != "" { + if agent, ok := r.agents[r.defaultAgentID]; ok { + return agent + } + } if agent, ok := r.agents["main"]; ok { return agent } - for _, agent := range r.agents { - return agent + ids := make([]string, 0, len(r.agents)) + for id := range r.agents { + ids = append(ids, id) + } + slices.Sort(ids) + if len(ids) > 0 { + return r.agents[ids[0]] } return nil } + +// SwitchModel hot-swaps provider and effective model at runtime. +// It updates all agents that currently use oldModel to newModel, and refreshes +// provider + fallback candidates atomically by replacing agent pointers. +func (r *AgentRegistry) SwitchModel( + cfg *config.Config, + oldModel string, + newModel string, + newProvider providers.LLMProvider, +) error { + r.mu.Lock() + if len(r.agents) == 0 { + r.mu.Unlock() + return fmt.Errorf("no agents registered") + } + + var oldProvider providers.LLMProvider + for id, agent := range r.agents { + if oldProvider == nil { + oldProvider = agent.Provider + } + + updated := *agent + if updated.Model == oldModel { + updated.Model = newModel + } + updated.Provider = newProvider + updated.Candidates = ResolveCandidatesForModel(cfg, cfg.Agents.Defaults.Provider, updated.Model, updated.Fallbacks) + r.agents[id] = &updated + } + r.mu.Unlock() + + if oldProvider != nil && oldProvider != newProvider { + if cp, ok := oldProvider.(providers.StatefulProvider); ok { + cp.Close() + } + } + + return nil +} diff --git a/pkg/agent/registry_test.go b/pkg/agent/registry_test.go index 518bb441f..a7ed4eaff 100644 --- a/pkg/agent/registry_test.go +++ b/pkg/agent/registry_test.go @@ -104,11 +104,29 @@ func TestAgentRegistry_GetDefaultAgent(t *testing.T) { }) registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) - // GetDefaultAgent first checks for "main", then returns any agent := registry.GetDefaultAgent() if agent == nil { t.Fatal("expected a default agent") } + if agent.ID != "beta" { + t.Fatalf("default agent ID = %q, want %q", agent.ID, "beta") + } +} + +func TestAgentRegistry_GetDefaultAgent_FallbackDeterministic(t *testing.T) { + cfg := testCfg([]config.AgentConfig{ + {ID: "zeta"}, + {ID: "alpha"}, + }) + registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) + + agent := registry.GetDefaultAgent() + if agent == nil { + t.Fatal("expected a default agent") + } + if agent.ID != "alpha" { + t.Fatalf("default agent fallback should be deterministic sorted first, got %q", agent.ID) + } } func TestAgentRegistry_CanSpawnSubagent(t *testing.T) { diff --git a/pkg/channels/base.go b/pkg/channels/base.go index 063a66523..44c406cf1 100644 --- a/pkg/channels/base.go +++ b/pkg/channels/base.go @@ -111,6 +111,12 @@ func NewBaseChannel( return bc } +// Config returns the underlying config object passed when constructing +// the BaseChannel. Callers must perform an appropriate type assertion. +func (c *BaseChannel) Config() any { + return c.config +} + // MaxMessageLength returns the maximum message length (in runes) for this channel. // A value of 0 means no limit. func (c *BaseChannel) MaxMessageLength() int { diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go index 1de910c83..e09f67a65 100644 --- a/pkg/channels/discord/discord.go +++ b/pkg/channels/discord/discord.go @@ -29,35 +29,37 @@ const ( type DiscordChannel struct { *channels.BaseChannel session *discordgo.Session - config config.DiscordConfig ctx context.Context cancel context.CancelFunc typingMu sync.Mutex typingStop map[string]chan struct{} // chatID → stop signal botUserID string // stored for mention checking + commands DiscordCommander // Discord command handler } -func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) { - session, err := discordgo.New("Bot " + cfg.Token) +func NewDiscordChannel(cfg *config.Config, bus *bus.MessageBus) (*DiscordChannel, error) { + session, err := discordgo.New("Bot " + cfg.Channels.Discord.Token) if err != nil { return nil, fmt.Errorf("failed to create discord session: %w", err) } - if err := applyDiscordProxy(session, cfg.Proxy); err != nil { + if err := applyDiscordProxy(session, cfg.Channels.Discord.Proxy); err != nil { return nil, err } - base := channels.NewBaseChannel("discord", cfg, bus, cfg.AllowFrom, + base := channels.NewBaseChannel("discord", cfg, bus, cfg.Channels.Discord.AllowFrom, channels.WithMaxMessageLength(2000), - channels.WithGroupTrigger(cfg.GroupTrigger), - channels.WithReasoningChannelID(cfg.ReasoningChannelID), + channels.WithGroupTrigger(cfg.Channels.Discord.GroupTrigger), + channels.WithReasoningChannelID(cfg.Channels.Discord.ReasoningChannelID), ) + commands := NewDiscordCommands(session, cfg, bus) + return &DiscordChannel{ BaseChannel: base, session: session, - config: cfg, ctx: context.Background(), typingStop: make(map[string]chan struct{}), + commands: commands, }, nil } @@ -235,11 +237,20 @@ func (c *DiscordChannel) EditMessage(ctx context.Context, chatID string, message // It sends a placeholder message that will later be edited to the actual // response via EditMessage (channels.MessageEditor). func (c *DiscordChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) { - if !c.config.Placeholder.Enabled { + // Placeholder configuration is stored in the Discord channel config + // under the global config passed to BaseChannel. + cfgAny := c.BaseChannel.Config() + cfg, ok := cfgAny.(*config.Config) + if !ok { return "", nil } - text := c.config.Placeholder.Text + phCfg := cfg.Channels.Discord.Placeholder + if !phCfg.Enabled { + return "", nil + } + + text := phCfg.Text if text == "" { text = "Thinking... 💭" } @@ -314,6 +325,16 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag content := m.Content + // Check for slash commands first + if strings.HasPrefix(strings.TrimSpace(content), "/") { + if err := c.handleSlashCommand(c.ctx, s, m); err != nil { + logger.DebugCF("discord", "Command error", map[string]any{ + "error": err.Error(), + }) + } + return + } + // In guild (group) channels, apply unified group trigger filtering // DMs (GuildID is empty) always get a response if m.GuildID != "" { @@ -519,3 +540,34 @@ func (c *DiscordChannel) stripBotMention(text string) string { text = strings.ReplaceAll(text, fmt.Sprintf("<@!%s>", c.botUserID), "") return strings.TrimSpace(text) } + +// handleSlashCommand processes slash commands and forwards to appropriate handler. +func (c *DiscordChannel) handleSlashCommand(ctx context.Context, s *discordgo.Session, m *discordgo.MessageCreate) error { + content := strings.TrimSpace(m.Content) + if !strings.HasPrefix(content, "/") { + return nil + } + + parts := strings.Fields(content) + if len(parts) == 0 { + return nil + } + + cmd := strings.TrimPrefix(parts[0], "/") + + switch cmd { + case "help": + return c.commands.Help(ctx, s, m) + case "start": + return c.commands.Help(ctx, s, m) // Start uses same help message + case "show": + return c.commands.Show(ctx, s, m) + case "list": + return c.commands.List(ctx, s, m) + case "switch": + return c.commands.Switch(ctx, s, m) + default: + // Unknown command - let it fall through to normal message handling + return nil + } +} diff --git a/pkg/channels/discord/discord_commands.go b/pkg/channels/discord/discord_commands.go new file mode 100644 index 000000000..ce01d0276 --- /dev/null +++ b/pkg/channels/discord/discord_commands.go @@ -0,0 +1,268 @@ +package discord + +import ( + "context" + "fmt" + "strings" + + "github.com/bwmarrin/discordgo" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/identity" +) + +type DiscordCommander interface { + Help(ctx context.Context, s *discordgo.Session, m *discordgo.MessageCreate) error + Show(ctx context.Context, s *discordgo.Session, m *discordgo.MessageCreate) error + List(ctx context.Context, s *discordgo.Session, m *discordgo.MessageCreate) error + Switch(ctx context.Context, s *discordgo.Session, m *discordgo.MessageCreate) error +} + +type cmd struct { + session *discordgo.Session + config *config.Config + bus *bus.MessageBus +} + +func NewDiscordCommands(session *discordgo.Session, cfg *config.Config, bus *bus.MessageBus) DiscordCommander { + return &cmd{ + session: session, + config: cfg, + bus: bus, + } +} + +// parseCommand extracts the command and arguments from message content. +func parseCommand(content string) (string, string) { + content = strings.TrimSpace(content) + if !strings.HasPrefix(content, "/") { + return "", "" + } + + parts := strings.SplitN(content, " ", 2) + cmd := strings.TrimPrefix(parts[0], "/") + + if len(parts) < 2 { + return cmd, "" + } + + return cmd, strings.TrimSpace(parts[1]) +} + +func (c *cmd) Help(ctx context.Context, s *discordgo.Session, m *discordgo.MessageCreate) error { + msg := `**PicoClaw Commands** + +/start - Start the bot +/help - Show this help message +/show [model|channel] - Show current configuration +/list [models|channels] - List available options +/switch model - Switch to a different model + +**Examples:** +/switch model gpt-4 +/switch model claude-sonnet-4.6 + +Use /list models to see all available models. +` + + _, err := s.ChannelMessageSend(m.ChannelID, msg) + return err +} + +func (c *cmd) Show(ctx context.Context, s *discordgo.Session, m *discordgo.MessageCreate) error { + cmd, args := parseCommand(m.Content) + if cmd != "show" { + return fmt.Errorf("invalid command format") + } + + if args == "" { + _, err := s.ChannelMessageSend(m.ChannelID, "Usage: /show [model|channel]") + return err + } + + var response string + switch args { + case "model": + currentModel := c.config.Agents.Defaults.GetModelName() + provider := c.config.Agents.Defaults.Provider + response = fmt.Sprintf("**Current Model:** %s\n**Provider:** %s", currentModel, provider) + case "channel": + response = "**Current Channel:** discord" + default: + response = fmt.Sprintf("Unknown parameter: %s. Try 'model' or 'channel'.", args) + } + + _, err := s.ChannelMessageSend(m.ChannelID, response) + return err +} + +func (c *cmd) List(ctx context.Context, s *discordgo.Session, m *discordgo.MessageCreate) error { + cmd, args := parseCommand(m.Content) + if cmd != "list" { + return fmt.Errorf("invalid command format") + } + + if args == "" { + _, err := s.ChannelMessageSend(m.ChannelID, "Usage: /list [models|channels]") + return err + } + + var response string + switch args { + case "models": + response = c.formatModelsList() + case "channels": + response = c.formatChannelsList() + default: + response = fmt.Sprintf("Unknown parameter: %s. Try 'models' or 'channels'.", args) + } + + _, err := s.ChannelMessageSend(m.ChannelID, response) + return err +} + +func (c *cmd) Switch(ctx context.Context, s *discordgo.Session, m *discordgo.MessageCreate) error { + cmd, args := parseCommand(m.Content) + if cmd != "switch" { + return fmt.Errorf("invalid command format") + } + + if args == "" { + _, err := s.ChannelMessageSend(m.ChannelID, "Usage: /switch model \nUse /list models to see available models.") + return err + } + + // Parse "model " format + parts := strings.SplitN(args, " ", 2) + if len(parts) < 2 || parts[0] != "model" { + _, err := s.ChannelMessageSend(m.ChannelID, "Usage: /switch model \nUse /list models to see available models.") + return err + } + + modelName := strings.TrimSpace(parts[1]) + + // Optional: validate model exists to provide immediate feedback + if _, err := c.config.GetModelConfig(modelName); err != nil { + available := c.formatModelsList() + _, sendErr := c.session.ChannelMessageSend( + m.ChannelID, + fmt.Sprintf("❌ Model not found: %s\n\n**Available models:**\n%s", modelName, available), + ) + return sendErr + } + + if c.bus == nil { + _, err := c.session.ChannelMessageSend(m.ChannelID, "❌ Internal error: message bus not initialized") + return err + } + + // Forward a normalized switch command to the agent loop via the message bus + // so that the agent can apply the change and persist any in-memory state. + inbound := bus.InboundMessage{ + Channel: "discord", + SenderID: m.Author.ID, + Sender: bus.SenderInfo{ + Platform: "discord", + PlatformID: m.Author.ID, + CanonicalID: identity.BuildCanonicalID("discord", m.Author.ID), + Username: m.Author.Username, + DisplayName: m.Author.Username, + }, + ChatID: m.ChannelID, + Content: fmt.Sprintf("/switch model to %s", modelName), + MessageID: m.ID, + Metadata: map[string]string{ + "guild_id": m.GuildID, + "channel_id": m.ChannelID, + }, + } + + if err := c.bus.PublishInbound(ctx, inbound); err != nil { + _, sendErr := c.session.ChannelMessageSend(m.ChannelID, fmt.Sprintf("❌ Failed to switch model: %v", err)) + return sendErr + } + + // The agent will respond via the normal outbound flow; no immediate reply here. + return nil +} + +func (c *cmd) formatModelsList() string { + if len(c.config.ModelList) == 0 { + return "No models configured. Please check your configuration." + } + + currentModel := c.config.Agents.Defaults.GetModelName() + + var sb strings.Builder + sb.WriteString("**Available Models:**\n\n") + + for _, mc := range c.config.ModelList { + if mc.ModelName == "" { + continue + } + + prefix := " " + if mc.ModelName == currentModel { + prefix = "✓ " + } + + provider := "openai" + if strings.Contains(mc.Model, "/") { + protocolParts := strings.SplitN(mc.Model, "/", 2) + if len(protocolParts) > 0 { + provider = protocolParts[0] + } + } + + sb.WriteString(fmt.Sprintf("%s**%s** - %s (%s)\n", prefix, mc.ModelName, mc.Model, provider)) + } + + return sb.String() +} + +func (c *cmd) formatChannelsList() string { + var enabled []string + if c.config.Channels.Telegram.Enabled { + enabled = append(enabled, "telegram") + } + if c.config.Channels.WhatsApp.Enabled { + enabled = append(enabled, "whatsapp") + } + if c.config.Channels.Feishu.Enabled { + enabled = append(enabled, "feishu") + } + if c.config.Channels.Discord.Enabled { + enabled = append(enabled, "discord") + } + if c.config.Channels.Slack.Enabled { + enabled = append(enabled, "slack") + } + if c.config.Channels.LINE.Enabled { + enabled = append(enabled, "line") + } + if c.config.Channels.QQ.Enabled { + enabled = append(enabled, "qq") + } + if c.config.Channels.OneBot.Enabled { + enabled = append(enabled, "onebot") + } + if c.config.Channels.WeCom.Enabled { + enabled = append(enabled, "wecom") + } + if c.config.Channels.WeComApp.Enabled { + enabled = append(enabled, "wecom_app") + } + if c.config.Channels.WeComAIBot.Enabled { + enabled = append(enabled, "wecom_aibot") + } + if c.config.Channels.Pico.Enabled { + enabled = append(enabled, "pico") + } + + if len(enabled) == 0 { + return "No channels enabled." + } + + return fmt.Sprintf("Enabled channels:\n- %s", strings.Join(enabled, "\n- ")) +} diff --git a/pkg/channels/discord/init.go b/pkg/channels/discord/init.go index 15a539804..6e538bd1e 100644 --- a/pkg/channels/discord/init.go +++ b/pkg/channels/discord/init.go @@ -8,6 +8,6 @@ import ( func init() { channels.RegisterFactory("discord", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { - return NewDiscordChannel(cfg.Channels.Discord, b) + return NewDiscordChannel(cfg, b) }) } diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index f328f32b8..14752e1cf 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -93,7 +93,7 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann return &TelegramChannel{ BaseChannel: base, - commands: NewTelegramCommands(bot, cfg), + commands: NewTelegramCommands(bot, cfg, bus), bot: bot, config: cfg, chatIDs: make(map[string]int64), @@ -141,6 +141,10 @@ func (c *TelegramChannel) Start(ctx context.Context) error { return c.commands.List(ctx, message) }, th.CommandEqual("list")) + bh.HandleMessage(func(ctx *th.Context, message telego.Message) error { + return c.commands.Switch(ctx, message) + }, th.CommandEqual("switch")) + bh.HandleMessage(func(ctx *th.Context, message telego.Message) error { return c.handleMessage(ctx, &message) }, th.AnyMessage()) @@ -203,6 +207,10 @@ func (c *TelegramChannel) initBotCommands(ctx context.Context) error { Command: "list", Description: "List available options", }, + { + Command: "switch", + Description: "Switch to a different model", + }, } // Setting commands on each start will hit the rate limit very quickly, that's why we check if an update is needed diff --git a/pkg/channels/telegram/telegram_commands.go b/pkg/channels/telegram/telegram_commands.go index 496fc5e4f..149d48f79 100644 --- a/pkg/channels/telegram/telegram_commands.go +++ b/pkg/channels/telegram/telegram_commands.go @@ -7,7 +7,9 @@ import ( "github.com/mymmrac/telego" + "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/identity" ) type TelegramCommander interface { @@ -15,17 +17,20 @@ type TelegramCommander interface { Start(ctx context.Context, message telego.Message) error Show(ctx context.Context, message telego.Message) error List(ctx context.Context, message telego.Message) error + Switch(ctx context.Context, message telego.Message) error } type cmd struct { bot *telego.Bot config *config.Config + bus *bus.MessageBus } -func NewTelegramCommands(bot *telego.Bot, cfg *config.Config) TelegramCommander { +func NewTelegramCommands(bot *telego.Bot, cfg *config.Config, bus *bus.MessageBus) TelegramCommander { return &cmd{ bot: bot, config: cfg, + bus: bus, } } @@ -37,12 +42,31 @@ func commandArgs(text string) string { return strings.TrimSpace(parts[1]) } +func isTelegramSwitchAllowed(allowFrom config.FlexibleStringSlice, sender bus.SenderInfo) bool { + if len(allowFrom) == 0 { + return true + } + for _, allowedEntry := range allowFrom { + if identity.MatchAllowed(sender, allowedEntry) { + return true + } + } + return false +} + func (c *cmd) Help(ctx context.Context, message telego.Message) error { msg := `/start - Start the bot /help - Show this help message /show [model|channel] - Show current configuration /list [models|channels] - List available options - ` +/switch model - Switch to a different model + +**Examples:** +/switch model gpt-4 +/switch model claude-sonnet-4.6 + +Use /list models to see all available models. +` _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{ ChatID: telego.ChatID{ID: message.Chat.ID}, Text: msg, @@ -80,9 +104,10 @@ func (c *cmd) Show(ctx context.Context, message telego.Message) error { var response string switch args { case "model": + currentModel := c.config.Agents.Defaults.GetModelName() + provider := c.config.Agents.Defaults.Provider response = fmt.Sprintf("Current Model: %s (Provider: %s)", - c.config.Agents.Defaults.GetModelName(), - c.config.Agents.Defaults.Provider) + currentModel, provider) case "channel": response = "Current Channel: telegram" default: @@ -115,12 +140,7 @@ func (c *cmd) List(ctx context.Context, message telego.Message) error { var response string switch args { case "models": - provider := c.config.Agents.Defaults.Provider - if provider == "" { - provider = "configured default" - } - response = fmt.Sprintf("Configured Model: %s\nProvider: %s\n\nTo change models, update config.json", - c.config.Agents.Defaults.GetModelName(), provider) + response = c.formatModelsList() case "channels": var enabled []string @@ -154,3 +174,146 @@ func (c *cmd) List(ctx context.Context, message telego.Message) error { }) return err } + +func (c *cmd) Switch(ctx context.Context, message telego.Message) error { + if message.From == nil { + _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{ + ChatID: telego.ChatID{ID: message.Chat.ID}, + Text: "❌ Cannot determine sender", + ReplyParameters: &telego.ReplyParameters{ + MessageID: message.MessageID, + }, + }) + return err + } + + platformID := fmt.Sprintf("%d", message.From.ID) + sender := bus.SenderInfo{ + Platform: "telegram", + PlatformID: platformID, + CanonicalID: identity.BuildCanonicalID("telegram", platformID), + Username: message.From.Username, + DisplayName: message.From.FirstName, + } + if !isTelegramSwitchAllowed(c.config.Channels.Telegram.AllowFrom, sender) { + _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{ + ChatID: telego.ChatID{ID: message.Chat.ID}, + Text: "❌ You are not allowed to use this command.", + ReplyParameters: &telego.ReplyParameters{ + MessageID: message.MessageID, + }, + }) + return err + } + + content := strings.TrimSpace(message.Text) + content = strings.TrimPrefix(content, "/switch") + content = strings.TrimSpace(content) + + parts := strings.SplitN(content, " ", 2) + if len(parts) < 2 || parts[0] != "model" { + _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{ + ChatID: telego.ChatID{ID: message.Chat.ID}, + Text: "Usage: /switch model \nUse /list models to see available models.", + ReplyParameters: &telego.ReplyParameters{ + MessageID: message.MessageID, + }, + }) + return err + } + + modelName := strings.TrimSpace(parts[1]) + if modelName == "" { + _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{ + ChatID: telego.ChatID{ID: message.Chat.ID}, + Text: "Usage: /switch model \nUse /list models to see available models.", + ReplyParameters: &telego.ReplyParameters{ + MessageID: message.MessageID, + }, + }) + return err + } + + // Optional: validate model exists to provide immediate feedback + if _, err := c.config.GetModelConfig(modelName); err != nil { + _, sendErr := c.bot.SendMessage(ctx, &telego.SendMessageParams{ + ChatID: telego.ChatID{ID: message.Chat.ID}, + Text: fmt.Sprintf("❌ Model not found: %s\n\n%s", modelName, c.formatModelsList()), + ReplyParameters: &telego.ReplyParameters{ + MessageID: message.MessageID, + }, + }) + return sendErr + } + + if c.bus == nil { + _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{ + ChatID: telego.ChatID{ID: message.Chat.ID}, + Text: "❌ Internal error: message bus not initialized", + ReplyParameters: &telego.ReplyParameters{ + MessageID: message.MessageID, + }, + }) + return err + } + + inbound := bus.InboundMessage{ + Channel: "telegram", + SenderID: platformID, + Sender: sender, + ChatID: fmt.Sprintf("%d", message.Chat.ID), + Content: fmt.Sprintf("/switch model to %s", modelName), + MessageID: fmt.Sprintf("%d", message.MessageID), + Metadata: map[string]string{ + "is_group": fmt.Sprintf("%t", message.Chat.Type != "private"), + }, + } + + if err := c.bus.PublishInbound(ctx, inbound); err != nil { + _, sendErr := c.bot.SendMessage(ctx, &telego.SendMessageParams{ + ChatID: telego.ChatID{ID: message.Chat.ID}, + Text: fmt.Sprintf("❌ Failed to switch model: %v", err), + ReplyParameters: &telego.ReplyParameters{ + MessageID: message.MessageID, + }, + }) + return sendErr + } + + // The agent will respond via the normal outbound flow; no immediate reply here. + return nil +} + +func (c *cmd) formatModelsList() string { + if len(c.config.ModelList) == 0 { + return "No models configured. Please check your configuration." + } + + currentModel := c.config.Agents.Defaults.GetModelName() + + var sb strings.Builder + sb.WriteString("*Available Models:*\n\n") + + for _, mc := range c.config.ModelList { + if mc.ModelName == "" { + continue + } + + prefix := " " + if mc.ModelName == currentModel { + prefix = "✓ " + } + + providerStr := "openai" + if strings.Contains(mc.Model, "/") { + protocolParts := strings.SplitN(mc.Model, "/", 2) + if len(protocolParts) > 0 { + providerStr = protocolParts[0] + } + } + + sb.WriteString(fmt.Sprintf("%s%s - %s (%s)\n", prefix, mc.ModelName, mc.Model, providerStr)) + } + + return sb.String() +} diff --git a/pkg/channels/telegram/telegram_commands_test.go b/pkg/channels/telegram/telegram_commands_test.go new file mode 100644 index 000000000..fc2a64225 --- /dev/null +++ b/pkg/channels/telegram/telegram_commands_test.go @@ -0,0 +1,115 @@ +package telegram + +import ( + "context" + "testing" + "time" + + "github.com/mymmrac/telego" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/identity" +) + +func TestIsTelegramSwitchAllowed(t *testing.T) { + sender := bus.SenderInfo{ + Platform: "telegram", + PlatformID: "123", + CanonicalID: identity.BuildCanonicalID("telegram", "123"), + Username: "alice", + } + + tests := []struct { + name string + allowFrom config.FlexibleStringSlice + want bool + }{ + { + name: "empty allowlist allows all", + allowFrom: config.FlexibleStringSlice{}, + want: true, + }, + { + name: "matches raw platform id", + allowFrom: config.FlexibleStringSlice{"123"}, + want: true, + }, + { + name: "matches canonical id", + allowFrom: config.FlexibleStringSlice{"telegram:123"}, + want: true, + }, + { + name: "non matching denied", + allowFrom: config.FlexibleStringSlice{"999"}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isTelegramSwitchAllowed(tt.allowFrom, sender) + if got != tt.want { + t.Fatalf("isTelegramSwitchAllowed() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSwitchPublishesNormalizedCommand(t *testing.T) { + msgBus := bus.NewMessageBus() + t.Cleanup(msgBus.Close) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + ModelName: "model-a", + }, + }, + Channels: config.ChannelsConfig{ + Telegram: config.TelegramConfig{ + AllowFrom: config.FlexibleStringSlice{}, + }, + }, + ModelList: []config.ModelConfig{ + {ModelName: "model-a", Model: "codex-cli/codex"}, + }, + } + + commander := &cmd{ + config: cfg, + bus: msgBus, + // bot is intentionally nil: success path should not call SendMessage + } + + message := telego.Message{ + MessageID: 7, + Text: "/switch model model-a", + Chat: telego.Chat{ + ID: 42, + }, + From: &telego.User{ + ID: 123, + Username: "alice", + FirstName: "Alice", + }, + } + + if err := commander.Switch(context.Background(), message); err != nil { + t.Fatalf("Switch() error = %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + got, ok := msgBus.ConsumeInbound(ctx) + if !ok { + t.Fatal("expected inbound switch command") + } + if got.Content != "/switch model to model-a" { + t.Fatalf("inbound content = %q, want %q", got.Content, "/switch model to model-a") + } + if got.Channel != "telegram" || got.ChatID != "42" { + t.Fatalf("unexpected inbound routing: channel=%q chat=%q", got.Channel, got.ChatID) + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index f40e05e1c..3dcd94abe 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -832,3 +832,17 @@ func (c *Config) ValidateModelList() error { } return nil } + +// SetDefaultModel sets the default model for all agents. +// It validates the model exists in ModelList before updating. +func (c *Config) SetDefaultModel(modelName string) error { + // Validate model exists + if _, err := c.GetModelConfig(modelName); err != nil { + return fmt.Errorf("model %q not found: %w", modelName, err) + } + + // Update the default model name + c.Agents.Defaults.ModelName = modelName + + return nil +}