feat(agent): enhance agent model management and command handling

- Added model switching functionality for agents, allowing dynamic model changes at runtime.
- Introduced a new command handler for Telegram and Discord channels to facilitate model switching.
- Updated agent registry to manage default agent selection and model resolution.
- Enhanced configuration management to support default model settings.
- Improved candidate resolution logic for agent instances.
- Added tests for default agent retrieval and fallback behavior.
This commit is contained in:
damon 2026-03-04 21:55:21 +08:00
parent a00ecedeb6
commit 09e3aacf88
16 changed files with 1011 additions and 54 deletions

1
.gitignore vendored
View file

@ -47,3 +47,4 @@ dist/
# Windows Application Icon/Resource
*.syso
AGENTS.md

3
go.mod
View file

@ -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

View file

@ -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.

View file

@ -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 {

110
pkg/agent/model_switch.go Normal file
View file

@ -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"
}

View file

@ -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")
}
}

View file

@ -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
}

View file

@ -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) {

View file

@ -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 {

View file

@ -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
}
}

View file

@ -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 <name> - 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 <name>\nUse /list models to see available models.")
return err
}
// Parse "model <name>" format
parts := strings.SplitN(args, " ", 2)
if len(parts) < 2 || parts[0] != "model" {
_, err := s.ChannelMessageSend(m.ChannelID, "Usage: /switch model <name>\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- "))
}

View file

@ -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)
})
}

View file

@ -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

View file

@ -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 <name> - 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 <name>\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 <name>\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()
}

View file

@ -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)
}
}

View file

@ -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
}