feat: add /model slash command to show and switch agent model (#420)

This commit is contained in:
Cameron
2026-02-26 16:47:06 -08:00
committed by GitHub
parent 3bf245a84e
commit 7658538b73
12 changed files with 162 additions and 83 deletions

View File

@@ -56,7 +56,7 @@ export class DiscordAdapter implements ChannelAdapter {
private attachmentsMaxBytes?: number;
onMessage?: (msg: InboundMessage) => Promise<void>;
onCommand?: (command: string, chatId?: string) => Promise<string | null>;
onCommand?: (command: string, chatId?: string, args?: string) => Promise<string | null>;
constructor(config: DiscordConfig) {
this.config = {
@@ -246,14 +246,16 @@ Ask the bot owner to approve with:
if (!content && attachments.length === 0) return;
if (content.startsWith('/')) {
const command = content.slice(1).split(/\s+/)[0]?.toLowerCase();
const parts = content.slice(1).split(/\s+/);
const command = parts[0]?.toLowerCase();
const cmdArgs = parts.slice(1).join(' ') || undefined;
if (command === 'help' || command === 'start') {
await message.channel.send(HELP_TEXT);
return;
}
if (this.onCommand) {
if (command === 'status' || command === 'reset' || command === 'heartbeat') {
const result = await this.onCommand(command, message.channel.id);
if (command === 'status' || command === 'reset' || command === 'heartbeat' || command === 'model') {
const result = await this.onCommand(command, message.channel.id, cmdArgs);
if (result) {
await message.channel.send(result);
}

View File

@@ -163,7 +163,7 @@ export class SignalAdapter implements ChannelAdapter {
private baseUrl: string;
onMessage?: (msg: InboundMessage) => Promise<void>;
onCommand?: (command: string, chatId?: string) => Promise<string | null>;
onCommand?: (command: string, chatId?: string, args?: string) => Promise<string | null>;
constructor(config: SignalConfig) {
this.config = {
@@ -810,12 +810,12 @@ This code expires in 1 hour.`;
}
// Handle slash commands
const command = parseCommand(messageText);
if (command) {
if (command === 'help' || command === 'start') {
const parsed = parseCommand(messageText);
if (parsed) {
if (parsed.command === 'help' || parsed.command === 'start') {
await this.sendMessage({ chatId, text: HELP_TEXT });
} else if (this.onCommand) {
const result = await this.onCommand(command, chatId);
const result = await this.onCommand(parsed.command, chatId, parsed.args || undefined);
if (result) await this.sendMessage({ chatId, text: result });
}
return; // Don't pass commands to agent

View File

@@ -40,7 +40,7 @@ export class SlackAdapter implements ChannelAdapter {
private attachmentsMaxBytes?: number;
onMessage?: (msg: InboundMessage) => Promise<void>;
onCommand?: (command: string, chatId?: string) => Promise<string | null>;
onCommand?: (command: string, chatId?: string, args?: string) => Promise<string | null>;
constructor(config: SlackConfig) {
this.config = config;
@@ -114,12 +114,12 @@ export class SlackAdapter implements ChannelAdapter {
}
// Handle slash commands
const command = parseCommand(text);
if (command) {
if (command === 'help' || command === 'start') {
const parsed = parseCommand(text);
if (parsed) {
if (parsed.command === 'help' || parsed.command === 'start') {
await say(await markdownToSlackMrkdwn(HELP_TEXT));
} else if (this.onCommand) {
const result = await this.onCommand(command, channelId);
const result = await this.onCommand(parsed.command, channelId, parsed.args || undefined);
if (result) await say(await markdownToSlackMrkdwn(result));
}
return; // Don't pass commands to agent
@@ -231,12 +231,12 @@ export class SlackAdapter implements ChannelAdapter {
}
// Handle slash commands
const command = parseCommand(text);
if (command) {
if (command === 'help' || command === 'start') {
const parsed = parseCommand(text);
if (parsed) {
if (parsed.command === 'help' || parsed.command === 'start') {
await this.sendMessage({ chatId: channelId, text: HELP_TEXT, threadId: threadTs });
} else if (this.onCommand) {
const result = await this.onCommand(command, channelId);
const result = await this.onCommand(parsed.command, channelId, parsed.args || undefined);
if (result) await this.sendMessage({ chatId: channelId, text: result, threadId: threadTs });
}
return; // Don't pass commands to agent

View File

@@ -78,7 +78,7 @@ export class TelegramMTProtoAdapter implements ChannelAdapter {
private pendingPairingApprovals = new Map<number, { code: string; userId: string; username: string }>();
onMessage?: (msg: InboundMessage) => Promise<void>;
onCommand?: (command: string, chatId?: string) => Promise<string | null>;
onCommand?: (command: string, chatId?: string, args?: string) => Promise<string | null>;
constructor(config: TelegramMTProtoConfig) {
this.config = {

View File

@@ -44,7 +44,7 @@ export class TelegramAdapter implements ChannelAdapter {
private attachmentsMaxBytes?: number;
onMessage?: (msg: InboundMessage) => Promise<void>;
onCommand?: (command: string, chatId?: string) => Promise<string | null>;
onCommand?: (command: string, chatId?: string, args?: string) => Promise<string | null>;
constructor(config: TelegramConfig) {
this.config = {
@@ -259,6 +259,15 @@ export class TelegramAdapter implements ChannelAdapter {
}
});
// Handle /model [handle]
this.bot.command('model', async (ctx) => {
if (this.onCommand) {
const args = ctx.match?.trim() || undefined;
const result = await this.onCommand('model', String(ctx.chat.id), args);
await ctx.reply(result || 'No model info available');
}
});
// Handle text messages
this.bot.on('message:text', async (ctx) => {
const userId = ctx.from?.id;

View File

@@ -32,7 +32,7 @@ export interface ChannelAdapter {
// Event handlers (set by bot core)
onMessage?: (msg: InboundMessage) => Promise<void>;
onCommand?: (command: string, chatId?: string) => Promise<string | null>;
onCommand?: (command: string, chatId?: string, args?: string) => Promise<string | null>;
}
/**

View File

@@ -181,7 +181,7 @@ export class WhatsAppAdapter implements ChannelAdapter {
// Event handlers (set by bot core)
onMessage?: (msg: InboundMessage) => Promise<void>;
onCommand?: (command: string, chatId?: string) => Promise<string | null>;
onCommand?: (command: string, chatId?: string, args?: string) => Promise<string | null>;
// Pre-bound handlers (created once to avoid bind() overhead)
private boundHandleConnectionUpdate: (update: Partial<import("@whiskeysockets/baileys").ConnectionState>) => void;
@@ -809,12 +809,12 @@ export class WhatsAppAdapter implements ChannelAdapter {
}
// Handle slash commands (before debouncing)
const command = parseCommand(body);
if (command && !isHistory) {
if (command === 'help' || command === 'start') {
const parsed = parseCommand(body);
if (parsed && !isHistory) {
if (parsed.command === 'help' || parsed.command === 'start') {
await this.sendMessage({ chatId, text: HELP_TEXT });
} else if (this.onCommand) {
const result = await this.onCommand(command, chatId);
const result = await this.onCommand(parsed.command, chatId, parsed.args || undefined);
if (result) await this.sendMessage({ chatId, text: result });
}
return; // Don't pass commands to agent

View File

@@ -13,7 +13,7 @@ import type { ChannelAdapter } from '../channels/types.js';
import type { BotConfig, InboundMessage, TriggerContext } from './types.js';
import type { AgentSession } from './interfaces.js';
import { Store } from './store.js';
import { updateAgentName, getPendingApprovals, rejectApproval, cancelRuns, recoverOrphanedConversationApproval, getLatestRunError } from '../tools/letta-api.js';
import { updateAgentName, getPendingApprovals, rejectApproval, cancelRuns, recoverOrphanedConversationApproval, getLatestRunError, getAgentModel, updateAgentModel } from '../tools/letta-api.js';
import { installSkillsToAgent, withAgentSkillsOnPath, getAgentSkillExecutableDirs, isVoiceMemoConfigured } from '../skills/loader.js';
import { formatMessageEnvelope, formatGroupBatchEnvelope, type SessionContextOptions } from './formatter.js';
import type { GroupBatcher } from './group-batcher.js';
@@ -1358,7 +1358,7 @@ export class LettaBot implements AgentSession {
registerChannel(adapter: ChannelAdapter): void {
adapter.onMessage = (msg) => this.handleMessage(msg, adapter);
adapter.onCommand = (cmd, chatId) => this.handleCommand(cmd, adapter.id, chatId);
adapter.onCommand = (cmd, chatId, args) => this.handleCommand(cmd, adapter.id, chatId, args);
// Wrap outbound methods when any redaction layer is active.
// Secrets are enabled by default unless explicitly disabled.
@@ -1419,8 +1419,8 @@ export class LettaBot implements AgentSession {
// Commands
// =========================================================================
private async handleCommand(command: string, channelId?: string, chatId?: string): Promise<string | null> {
log.info(`Received: /${command}`);
private async handleCommand(command: string, channelId?: string, chatId?: string, args?: string): Promise<string | null> {
log.info(`Received: /${command}${args ? ` ${args}` : ''}`);
switch (command) {
case 'status': {
const info = this.store.getInfo();
@@ -1470,6 +1470,32 @@ export class LettaBot implements AgentSession {
return `Conversation reset for ${scope}. Other conversations are unaffected. (Agent memory is preserved.)`;
}
}
case 'model': {
const agentId = this.store.agentId;
if (!agentId) return 'No agent configured.';
if (args) {
const success = await updateAgentModel(agentId, args);
if (success) {
return `Model updated to: ${args}`;
}
return 'Failed to update model. Check the handle is valid.\nUse /model to list available models.';
}
const current = await getAgentModel(agentId);
const { models: recommendedModels } = await import('../utils/model-selection.js');
const lines = [
`Current model: ${current || '(unknown)'}`,
'',
'Recommended models:',
];
for (const m of recommendedModels) {
const marker = m.handle === current ? ' (current)' : '';
lines.push(` ${m.label} - ${m.handle}${marker}`);
}
lines.push('', 'Use /model <handle> to switch.');
return lines.join('\n');
}
default:
return null;
}

View File

@@ -3,24 +3,28 @@ import { parseCommand, COMMANDS, HELP_TEXT } from './commands.js';
describe('parseCommand', () => {
describe('valid commands', () => {
it('returns "status" for /status', () => {
expect(parseCommand('/status')).toBe('status');
it('returns { command, args } for /status', () => {
expect(parseCommand('/status')).toEqual({ command: 'status', args: '' });
});
it('returns "heartbeat" for /heartbeat', () => {
expect(parseCommand('/heartbeat')).toBe('heartbeat');
it('returns { command, args } for /heartbeat', () => {
expect(parseCommand('/heartbeat')).toEqual({ command: 'heartbeat', args: '' });
});
it('returns "help" for /help', () => {
expect(parseCommand('/help')).toBe('help');
it('returns { command, args } for /help', () => {
expect(parseCommand('/help')).toEqual({ command: 'help', args: '' });
});
it('returns "start" for /start', () => {
expect(parseCommand('/start')).toBe('start');
it('returns { command, args } for /start', () => {
expect(parseCommand('/start')).toEqual({ command: 'start', args: '' });
});
it('returns "reset" for /reset', () => {
expect(parseCommand('/reset')).toBe('reset');
it('returns { command, args } for /reset', () => {
expect(parseCommand('/reset')).toEqual({ command: 'reset', args: '' });
});
it('returns { command, args } for /model', () => {
expect(parseCommand('/model')).toEqual({ command: 'model', args: '' });
});
});
@@ -47,19 +51,33 @@ describe('parseCommand', () => {
});
describe('command parsing', () => {
it('handles commands with extra text after', () => {
expect(parseCommand('/status please')).toBe('status');
expect(parseCommand('/help me')).toBe('help');
it('captures trailing text as args', () => {
expect(parseCommand('/status please')).toEqual({ command: 'status', args: 'please' });
expect(parseCommand('/help me')).toEqual({ command: 'help', args: 'me' });
});
it('is case insensitive', () => {
expect(parseCommand('/STATUS')).toBe('status');
expect(parseCommand('/Help')).toBe('help');
expect(parseCommand('/HEARTBEAT')).toBe('heartbeat');
expect(parseCommand('/STATUS')).toEqual({ command: 'status', args: '' });
expect(parseCommand('/Help')).toEqual({ command: 'help', args: '' });
expect(parseCommand('/HEARTBEAT')).toEqual({ command: 'heartbeat', args: '' });
});
it('handles commands with leading/trailing whitespace in args', () => {
expect(parseCommand('/status ')).toBe('status');
it('handles commands with trailing whitespace', () => {
expect(parseCommand('/status ')).toEqual({ command: 'status', args: '' });
});
it('parses /model with a handle argument', () => {
expect(parseCommand('/model anthropic/claude-sonnet-4-5-20250929')).toEqual({
command: 'model',
args: 'anthropic/claude-sonnet-4-5-20250929',
});
});
it('preserves multi-word args', () => {
expect(parseCommand('/model some handle with spaces')).toEqual({
command: 'model',
args: 'some handle with spaces',
});
});
});
});
@@ -71,11 +89,11 @@ describe('COMMANDS', () => {
expect(COMMANDS).toContain('reset');
expect(COMMANDS).toContain('help');
expect(COMMANDS).toContain('start');
expect(COMMANDS).toContain('reset');
expect(COMMANDS).toContain('model');
});
it('has exactly 5 commands', () => {
expect(COMMANDS).toHaveLength(5);
it('has exactly 6 commands', () => {
expect(COMMANDS).toHaveLength(6);
});
});
@@ -84,6 +102,7 @@ describe('HELP_TEXT', () => {
expect(HELP_TEXT).toContain('/status');
expect(HELP_TEXT).toContain('/heartbeat');
expect(HELP_TEXT).toContain('/help');
expect(HELP_TEXT).toContain('/model');
});
it('contains LettaBot branding', () => {

View File

@@ -4,25 +4,34 @@
* Shared command parsing and help text for all channels.
*/
export const COMMANDS = ['status', 'heartbeat', 'reset', 'help', 'start'] as const;
export const COMMANDS = ['status', 'heartbeat', 'reset', 'help', 'start', 'model'] as const;
export type Command = typeof COMMANDS[number];
export interface ParsedCommand {
command: Command;
args: string;
}
export const HELP_TEXT = `LettaBot - AI assistant with persistent memory
Commands:
/status - Show current status
/heartbeat - Trigger heartbeat
/reset - Reset conversation (keeps agent memory)
/model - Show current model and list available models
/model <handle> - Switch to a different model
/help - Show this message
Just send a message to get started!`;
/**
* Parse a slash command from message text.
* Returns the command name if valid, null otherwise.
* Returns the command and any trailing arguments, or null if not a valid command.
*/
export function parseCommand(text: string | undefined | null): Command | null {
export function parseCommand(text: string | undefined | null): ParsedCommand | null {
if (!text?.startsWith('/')) return null;
const cmd = text.slice(1).split(/\s+/)[0]?.toLowerCase();
return COMMANDS.includes(cmd as Command) ? (cmd as Command) : null;
const parts = text.slice(1).split(/\s+/);
const cmd = parts[0]?.toLowerCase();
if (!COMMANDS.includes(cmd as Command)) return null;
return { command: cmd as Command, args: parts.slice(1).join(' ') };
}

View File

@@ -1,38 +1,45 @@
[
{
"id": "sonnet-4.5",
"handle": "anthropic/claude-sonnet-4-5-20250929",
"label": "Sonnet 4.5",
"description": "The recommended default model",
"id": "sonnet-4.6",
"handle": "anthropic/claude-sonnet-4-6",
"label": "Sonnet 4.6",
"description": "Anthropic's new Sonnet model",
"isDefault": true,
"isFeatured": true
},
{
"id": "opus",
"handle": "anthropic/claude-opus-4-5-20251101",
"label": "Opus 4.5",
"id": "opus-4.6",
"handle": "anthropic/claude-opus-4-6",
"label": "Opus 4.6",
"description": "Anthropic's best model",
"isFeatured": true
},
{
"id": "haiku",
"handle": "anthropic/claude-haiku-4-5-20251001",
"handle": "anthropic/claude-haiku-4-5",
"label": "Haiku 4.5",
"description": "Anthropic's fastest model",
"isFeatured": true
},
{
"id": "gpt-5.2-medium",
"handle": "openai/gpt-5.2",
"label": "GPT-5.2",
"description": "Latest general-purpose GPT (med reasoning)",
"id": "gpt-5.3-codex",
"handle": "openai/gpt-5.3-codex",
"label": "GPT-5.3 Codex",
"description": "OpenAI's best coding model",
"isFeatured": true
},
{
"id": "gemini-3",
"handle": "google_ai/gemini-3-pro-preview",
"label": "Gemini 3 Pro",
"description": "Google's smartest model",
"id": "gpt-5.2",
"handle": "openai/gpt-5.2",
"label": "GPT-5.2",
"description": "Latest general-purpose GPT",
"isFeatured": true
},
{
"id": "gemini-3.1",
"handle": "google_ai/gemini-3.1-pro-preview",
"label": "Gemini 3.1 Pro",
"description": "Google's latest and smartest model",
"isFeatured": true
},
{
@@ -43,17 +50,24 @@
"isFeatured": true
},
{
"id": "glm-4.7",
"handle": "zai/glm-4.7",
"label": "GLM-4.7",
"id": "kimi-k2.5",
"handle": "openrouter/moonshotai/kimi-k2.5",
"label": "Kimi K2.5",
"description": "Kimi's latest coding model",
"isFeatured": true
},
{
"id": "glm-5",
"handle": "zai/glm-5",
"label": "GLM-5",
"description": "zAI's latest coding model",
"isFeatured": true,
"free": true
},
{
"id": "minimax-m2.1",
"handle": "minimax/MiniMax-M2.1",
"label": "MiniMax 2.1",
"id": "minimax-m2.5",
"handle": "minimax/MiniMax-M2.5",
"label": "MiniMax 2.5",
"description": "MiniMax's latest coding model",
"isFeatured": true,
"free": true

View File

@@ -17,7 +17,7 @@ export class MockChannelAdapter implements ChannelAdapter {
private responseResolvers: Array<(msg: OutboundMessage) => void> = [];
onMessage?: (msg: InboundMessage) => Promise<void>;
onCommand?: (command: string) => Promise<string | null>;
onCommand?: (command: string, chatId?: string, args?: string) => Promise<string | null>;
async start(): Promise<void> {
this.running = true;
@@ -74,12 +74,12 @@ export class MockChannelAdapter implements ChannelAdapter {
const chatId = options.chatId || 'test-chat-123';
// Handle slash commands locally (like real channels do)
const command = parseCommand(text);
if (command) {
if (command === 'help' || command === 'start') {
const parsed = parseCommand(text);
if (parsed) {
if (parsed.command === 'help' || parsed.command === 'start') {
return HELP_TEXT;
} else if (this.onCommand) {
const result = await this.onCommand(command);
const result = await this.onCommand(parsed.command, chatId, parsed.args || undefined);
return result || '(No response)';
}
return '(Command not handled)';