feat: implement model persistence with per-session overrides

- Add session_config SQLite table for per-session settings
- Update routing to support session override → agent config → global default resolution chain
- Upgrade WebChat SessionBridge from NativeAgent to AgentOrchestrator
- Add /model, /local, /cloud commands to Telegram adapter
- Add /model command to WebChat gateway handlers
- Clear session overrides on /reset command
- Pass memoryStore and config through to SessionBridge
- Add comprehensive tests for all new functionality

Fixes model persistence bug where TUI model changes didn't affect WebChat/Telegram sessions. Now:
- TUI /model sets global default (persists across restarts, affects all new sessions)
- WebChat/Telegram /model sets session override (only that conversation, cleared on /reset)
- WebChat sessions gain AgentOrchestrator features (delegation, compaction, memory)
This commit is contained in:
William Valentin
2026-02-11 21:51:38 -08:00
parent b0092c8284
commit a8a2c59313
12 changed files with 1175 additions and 46 deletions
+3
View File
@@ -56,6 +56,9 @@ describe('NativeAgent', () => {
addMessage: vi.fn(),
clear: vi.fn(),
replaceHistory: vi.fn(),
getConfig: vi.fn().mockReturnValue(undefined),
setConfig: vi.fn(),
deleteConfig: vi.fn(),
};
const agent = new NativeAgent({
+5 -2
View File
@@ -62,10 +62,13 @@ describe('TelegramAdapter', () => {
// .use() for auth middleware
expect(mockUse).toHaveBeenCalledTimes(1);
// .command() for /start and /reset
expect(mockCommand).toHaveBeenCalledTimes(2);
// .command() for /start, /reset, /model, /local, /cloud
expect(mockCommand).toHaveBeenCalledTimes(5);
expect(mockCommand.mock.calls[0][0]).toBe('start');
expect(mockCommand.mock.calls[1][0]).toBe('reset');
expect(mockCommand.mock.calls[2][0]).toBe('model');
expect(mockCommand.mock.calls[3][0]).toBe('local');
expect(mockCommand.mock.calls[4][0]).toBe('cloud');
// .on('message:text', ...) for text handler
expect(mockOn).toHaveBeenCalledWith('message:text', expect.any(Function));
// .start() to begin long polling
+46
View File
@@ -163,6 +163,52 @@ export class TelegramAdapter implements ChannelAdapter {
await ctx.reply('Conversation reset.');
});
this.bot.command('model', async (ctx) => {
if (!this.messageHandler) return;
const args = ctx.message?.text?.replace(/^\/model\s*/, '').trim() ?? '';
this.messageHandler({
id: String(ctx.message?.message_id ?? Date.now()),
channel: 'telegram',
senderId: String(ctx.chat.id),
senderName: ctx.from?.first_name,
text: `/model ${args}`.trim(),
timestamp: Date.now(),
metadata: {
isCommand: true,
command: 'model',
commandArgs: args || undefined,
},
});
});
this.bot.command('local', async (ctx) => {
if (!this.messageHandler) return;
this.messageHandler({
id: String(ctx.message?.message_id ?? Date.now()),
channel: 'telegram',
senderId: String(ctx.chat.id),
senderName: ctx.from?.first_name,
text: '/model local',
timestamp: Date.now(),
metadata: { isCommand: true, command: 'model', commandArgs: 'local' },
});
});
this.bot.command('cloud', async (ctx) => {
if (!this.messageHandler) return;
this.messageHandler({
id: String(ctx.message?.message_id ?? Date.now()),
channel: 'telegram',
senderId: String(ctx.chat.id),
senderName: ctx.from?.first_name,
text: '/model default',
timestamp: Date.now(),
metadata: { isCommand: true, command: 'model', commandArgs: 'default' },
});
});
// ── Text message handler ──
this.bot.on('message:text', async (ctx) => {
+1 -1
View File
@@ -120,7 +120,7 @@ export async function startDaemon(config: Config): Promise<DaemonContext> {
const gateway = createGateway({
config, sessionManager, modelRouter, systemPrompt, toolRegistry, toolExecutor,
channelRegistry, pairingManager, lifecycle,
channelRegistry, pairingManager, lifecycle, memoryStore,
getChannelAgents: () => channelAgents,
});
+55 -1
View File
@@ -58,9 +58,18 @@ export function createMessageRouter(deps: {
if (!entry) {
const session = deps.sessionManager.getSession(channel, senderId);
// Read per-session model tier override (persisted in SQLite)
const sessionTierOverride = session.getConfig('modelTier') as ModelTier | undefined;
// Resolution chain: metadata (cron) → session override → agent config → global default
const effectiveTier = tierFromMetadata
?? sessionTierOverride
?? agentConfig?.modelTier
?? deps.config.agents.primary_tier
?? 'default';
// Use agent config overrides where available, falling back to global config
const effectiveSystemPrompt = agentConfig?.systemPrompt ?? deps.systemPrompt;
const effectiveTier = tierFromMetadata ?? agentConfig?.modelTier ?? deps.config.agents.primary_tier ?? 'default';
const effectiveProvider = deps.config.models.default.provider;
const delegationConfig: DelegationConfig = {
@@ -166,6 +175,46 @@ export function createMessageRouter(deps: {
if (msg.metadata?.isCommand) {
if (msg.metadata.command === 'reset') {
agent.reset();
// Clear per-session config overrides
const session = deps.sessionManager.getSession(msg.channel, msg.senderId);
session.deleteConfig('modelTier');
return;
}
if (msg.metadata.command === 'model') {
const modelArg = msg.metadata.commandArgs as string | undefined;
const session = deps.sessionManager.getSession(msg.channel, msg.senderId);
if (!modelArg) {
// Show current model tier
const currentTier = agent.getModelTier();
const sessionOverride = session.getConfig('modelTier');
const available = deps.modelRouter.getAvailableTiers();
const labels = deps.modelRouter.getAllLabels();
const lines = [`Active tier: ${currentTier}${sessionOverride ? ' (session override)' : ''}`];
for (const tier of available) {
const label = labels[tier] ?? 'unknown';
const marker = tier === currentTier ? ' ←' : '';
lines.push(` ${tier}: ${label}${marker}`);
}
await reply({ text: lines.join('\n'), replyTo: msg.id });
return;
}
// Validate tier
const validTiers = deps.modelRouter.getAvailableTiers();
if (!validTiers.includes(modelArg as ModelTier)) {
await reply({ text: `Model tier not available: ${modelArg}`, replyTo: msg.id });
return;
}
// Persist to session config
session.setConfig('modelTier', modelArg);
// Update the orchestrator's agent tier
agent.setModelTier(modelArg as ModelTier);
const label = deps.modelRouter.getLabel(modelArg as ModelTier);
await reply({ text: `Switched to model: ${modelArg} (${label})`, replyTo: msg.id });
return;
}
if (msg.metadata.command === 'compact') {
@@ -215,8 +264,13 @@ export function createMessageRouter(deps: {
try {
// Determine if the active model supports native audio input
let effectiveTier: string = deps.config.agents.primary_tier ?? 'default';
const session = deps.sessionManager.getSession(msg.channel, msg.senderId);
const sessionTierOverride = session.getConfig('modelTier');
if (msg.metadata?.modelTier) {
effectiveTier = msg.metadata.modelTier as string;
} else if (sessionTierOverride) {
effectiveTier = sessionTierOverride;
} else if (deps.agentRouter && deps.agentConfigRegistry) {
const agentName = deps.agentRouter.resolve(msg.channel, msg.senderId);
if (agentName) {
+3
View File
@@ -13,6 +13,7 @@ import { SkillRegistry, SkillInstaller, loadAllSkills } from '../skills/index.js
import { assembleSystemPrompt } from '../prompt/index.js';
import { resolve } from 'path';
import { homedir } from 'os';
import type { MemoryStore } from '../memory/store.js';
// ── Skills ──────────────────────────────────────────────────────
@@ -121,6 +122,7 @@ export interface GatewayDeps {
pairingManager?: PairingManager;
lifecycle: Lifecycle;
getChannelAgents: () => Map<string, { orchestrator: AgentOrchestrator; collector: OutboundAttachmentCollector }> | null;
memoryStore?: MemoryStore;
}
export function createGateway(deps: GatewayDeps): GatewayServer {
@@ -144,6 +146,7 @@ export function createGateway(deps: GatewayDeps): GatewayServer {
config,
channelRegistry,
pairingManager,
memoryStore: deps.memoryStore,
restart: async () => {
console.log('Restart requested via gateway');
await lifecycle.shutdown();
+46 -1
View File
@@ -5,17 +5,20 @@ import type { SessionBridge } from '../session-bridge.js';
import type { LaneQueue } from '../lane-queue.js';
import type { MetricsCollector } from '../metrics.js';
import type { Attachment } from '../../channels/types.js';
import type { SessionManager } from '../../session/manager.js';
import type { ModelTier } from '../../models/router.js';
export interface AgentHandlerDeps {
sessionBridge: SessionBridge;
laneQueue: LaneQueue;
metrics?: MetricsCollector;
sessionManager?: SessionManager;
}
export function createAgentHandlers(deps: AgentHandlerDeps) {
return {
'agent.send': async (request: GatewayRequest, send: SendFn): Promise<OutboundMessage | void> => {
const params = request.params as { message?: string; connectionId?: string; attachments?: GatewayAttachment[]; metadata?: { isCommand?: boolean; command?: string } } | undefined;
const params = request.params as { message?: string; connectionId?: string; attachments?: GatewayAttachment[]; metadata?: { isCommand?: boolean; command?: string; commandArgs?: string } } | undefined;
if (!params?.message && !params?.metadata?.isCommand) {
return makeError(request.id, ErrorCode.InvalidRequest, 'message is required');
}
@@ -48,9 +51,51 @@ export function createAgentHandlers(deps: AgentHandlerDeps) {
try {
if (params.metadata.command === 'reset') {
agent.reset();
// Clear session config
const sessionId = deps.sessionBridge.getSessionId(connectionId);
if (sessionId && deps.sessionManager) {
deps.sessionManager.deleteSessionConfig('ws', sessionId, 'modelTier');
}
send(makeEvent(request.id, 'done', { content: 'Session reset.' }));
return;
}
if (params.metadata.command === 'model') {
const modelArg = params.metadata.commandArgs as string | undefined;
const sessionId = deps.sessionBridge.getSessionId(connectionId);
if (!modelArg) {
// Show current tier info
const currentTier = agent.getModelTier();
send(makeEvent(request.id, 'done', {
content: `Current model tier: ${currentTier}`,
}));
return;
}
// Validate tier
const validTiers: ModelTier[] = ['fast', 'default', 'complex', 'local'];
const tier = modelArg as ModelTier;
if (!validTiers.includes(tier)) {
send(makeEvent(request.id, 'done', {
content: `Invalid tier: ${modelArg}. Available: ${validTiers.join(', ')}`,
}));
return;
}
// Update agent tier
agent.setModelTier(tier);
// Persist to session config
if (sessionId && deps.sessionManager) {
deps.sessionManager.setSessionConfig('ws', sessionId, 'modelTier', tier);
}
send(makeEvent(request.id, 'done', {
content: `Switched to model tier: ${tier}`,
}));
return;
}
} finally {
deps.sessionBridge.setBusy(connectionId, false);
deps.metrics?.endRequest(requestId);
+5
View File
@@ -32,6 +32,7 @@ import type { ToolExecutor } from '../tools/executor.js';
import type { WebhookHandler } from '../automation/webhooks.js';
import type { GmailWatcher } from '../automation/gmail.js';
import type { PairingManager } from '../channels/pairing.js';
import type { MemoryStore } from '../memory/store.js';
export interface GatewayServerConfig {
port: number;
@@ -60,6 +61,7 @@ export interface GatewayServerConfig {
getTokenUsage?: () => TokenUsageEntry[];
/** Optional pairing manager for DM pairing code management via gateway. */
pairingManager?: PairingManager;
memoryStore?: MemoryStore;
}
export class GatewayServer {
@@ -82,6 +84,8 @@ export class GatewayServer {
systemPrompt: config.systemPrompt,
toolRegistry: config.toolRegistry,
toolExecutor: config.toolExecutor,
config: config.config,
memoryStore: config.memoryStore,
});
this.laneQueue = new LaneQueue();
@@ -127,6 +131,7 @@ export class GatewayServer {
sessionBridge: this.sessionBridge,
laneQueue: this.laneQueue,
metrics: this.metrics,
sessionManager: this.config.sessionManager,
});
// Config handlers (only if config object is provided)
+49 -37
View File
@@ -5,8 +5,10 @@ import type { ModelClient } from '../models/types.js';
import type { ModelRouter, ModelTier } from '../models/router.js';
import type { ToolRegistry } from '../tools/registry.js';
import type { ToolExecutor } from '../tools/executor.js';
import { NativeAgent } from '../backends/native/agent.js';
import { AgentOrchestrator, type DelegationConfig } from '../backends/native/orchestrator.js';
import type { ToolUseEvent } from '../backends/native/agent.js';
import type { MemoryStore } from '../memory/store.js';
import type { Config } from '../config/index.js';
export interface SessionBridgeConfig {
sessionManager: SessionManager;
@@ -14,40 +16,24 @@ export interface SessionBridgeConfig {
systemPrompt: string;
toolRegistry: ToolRegistry;
toolExecutor: ToolExecutor;
config?: Config;
memoryStore?: MemoryStore;
}
interface ClientEntry {
connectionId: string;
sessionId: string;
agent: NativeAgent;
agent: AgentOrchestrator;
busy: boolean;
}
export class SessionBridge {
private clients: Map<string, ClientEntry> = new Map();
private agents: Map<string, NativeAgent> = new Map();
private agents: Map<string, AgentOrchestrator> = new Map();
private config: SessionBridgeConfig;
/** Tracks the current model tier so new agents inherit it and existing agents stay in sync. */
private currentTier: ModelTier = 'default';
constructor(config: SessionBridgeConfig) {
this.config = config;
// If the model client is a ModelRouter, subscribe to tier changes
// so all WebChat agents stay in sync with TUI model switches.
if ('getClient' in config.modelClient) {
const router = config.modelClient as ModelRouter;
this.currentTier = router.getTier();
router.addOnTierChange((tier: ModelTier) => this.onTierChanged(tier));
}
}
/** Called when the ModelRouter's active tier changes. Updates all existing agents. */
private onTierChanged(tier: ModelTier): void {
this.currentTier = tier;
for (const agent of this.agents.values()) {
agent.setModelTier(tier);
}
}
/** Register a new WS connection. Returns the assigned connection ID. */
@@ -91,8 +77,8 @@ export class SessionBridge {
client.agent = agent;
}
/** Get the NativeAgent for a connection. */
getAgent(connectionId: string): NativeAgent | undefined {
/** Get the AgentOrchestrator for a connection. */
getAgent(connectionId: string): AgentOrchestrator | undefined {
return this.clients.get(connectionId)?.agent;
}
@@ -138,7 +124,13 @@ export class SessionBridge {
/** Get usage stats for a specific connection's agent. */
getUsage(connectionId: string): { inputTokens: number; outputTokens: number; calls: number } | undefined {
const agent = this.clients.get(connectionId)?.agent;
return agent?.getUsage();
if (!agent) {return undefined;}
const usage = agent.getUsage();
return {
inputTokens: usage.primary.inputTokens,
outputTokens: usage.primary.outputTokens,
calls: usage.primary.calls,
};
}
/** Get usage stats for all active sessions. Returns an array of per-session usage entries. */
@@ -165,33 +157,53 @@ export class SessionBridge {
const usage = client.agent.getUsage();
results.push({
sessionId: client.sessionId,
primary: { inputTokens: usage.inputTokens, outputTokens: usage.outputTokens, calls: usage.calls },
delegation: {} as Record<string, { inputTokens: number; outputTokens: number; calls: number }>,
total: {
inputTokens: usage.inputTokens,
outputTokens: usage.outputTokens,
calls: usage.calls,
estimatedCost: 0, // NativeAgent doesn't track cost; only AgentOrchestrator does
},
primary: usage.primary,
delegation: usage.delegation,
total: usage.total,
});
}
return results;
}
private getOrCreateAgent(sessionId: string): NativeAgent {
private getOrCreateAgent(sessionId: string): AgentOrchestrator {
let agent = this.agents.get(sessionId);
if (!agent) {
const session = this.config.sessionManager.getSession('ws', sessionId);
agent = new NativeAgent({
modelClient: this.config.modelClient,
const config = this.config.config;
// Read per-session tier override from session config
const sessionTier = session.getConfig?.('modelTier') as ModelTier | undefined;
const primaryTier = sessionTier ?? config?.agents.primary_tier ?? 'default';
const delegationConfig: DelegationConfig = {
compaction: config?.agents.delegation.compaction ?? 'fast',
memory_extraction: config?.agents.delegation.memory_extraction ?? 'fast',
classification: config?.agents.delegation.classification ?? 'fast',
tool_summarisation: config?.agents.delegation.tool_summarisation ?? 'fast',
complex_reasoning: config?.agents.delegation.complex_reasoning ?? 'complex',
};
agent = new AgentOrchestrator({
modelRouter: this.config.modelClient as ModelRouter,
systemPrompt: this.config.systemPrompt,
session,
toolRegistry: this.config.toolRegistry,
toolExecutor: this.config.toolExecutor,
primaryTier,
delegation: delegationConfig,
maxDelegationDepth: config?.agents.max_delegation_depth ?? 3,
maxIterations: config?.agents.max_iterations,
compaction: config?.compaction.enabled ? {
thresholdPct: config.compaction.threshold_pct,
keepTurns: config.compaction.keep_turns,
summaryMaxTokens: config.compaction.summary_max_tokens,
} : undefined,
modelName: config?.models.default.model,
contextWindow: config?.models.default.context_window,
memoryStore: this.config.memoryStore,
});
// Inherit the current model tier so the agent uses the same model as the TUI
agent.setModelTier(this.currentTier);
this.agents.set(sessionId, agent);
}
return agent;
+33
View File
@@ -8,6 +8,9 @@ export interface Session {
getHistory(): Message[];
clear(): void;
replaceHistory(messages: Message[]): void;
getConfig(key: string): string | undefined;
setConfig(key: string, value: string): void;
deleteConfig(key: string): void;
}
export class ManagedSession implements Session {
@@ -64,6 +67,18 @@ export class ManagedSession implements Session {
setHistory(messages: Message[]): void {
this.history = [...messages];
}
getConfig(key: string): string | undefined {
return this.store.getSessionConfig(this.id, key);
}
setConfig(key: string, value: string): void {
this.store.setSessionConfig(this.id, key, value);
}
deleteConfig(key: string): void {
this.store.deleteSessionConfig(this.id, key);
}
}
export class SessionManager {
@@ -129,4 +144,22 @@ export class SessionManager {
this.sessions.delete(id);
}
}
/** Get a session config value. */
getSessionConfig(frontend: string, userId: string, key: string): string | undefined {
const session = this.getSession(frontend, userId);
return session.getConfig(key);
}
/** Set a session config value. */
setSessionConfig(frontend: string, userId: string, key: string, value: string): void {
const session = this.getSession(frontend, userId);
session.setConfig(key, value);
}
/** Delete a session config value. */
deleteSessionConfig(frontend: string, userId: string, key: string): void {
const session = this.getSession(frontend, userId);
session.deleteConfig(key);
}
}
+59 -4
View File
@@ -36,6 +36,13 @@ export class SessionStore {
code_used TEXT NOT NULL,
PRIMARY KEY (channel, sender_id)
);
CREATE TABLE IF NOT EXISTS session_config (
session_id TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
PRIMARY KEY (session_id, key)
);
CREATE INDEX IF NOT EXISTS idx_session_config_session ON session_config(session_id);
`);
}
@@ -78,8 +85,11 @@ export class SessionStore {
}
clearSession(sessionId: string): void {
const stmt = this.db.prepare('DELETE FROM messages WHERE session_id = ?');
stmt.run(sessionId);
const transaction = this.db.transaction(() => {
this.db.prepare('DELETE FROM messages WHERE session_id = ?').run(sessionId);
this.db.prepare('DELETE FROM session_config WHERE session_id = ?').run(sessionId);
});
transaction();
}
listSessions(): string[] {
@@ -98,10 +108,12 @@ export class SessionStore {
if (stale.length === 0) {return [];}
const deleteStmt = this.db.prepare('DELETE FROM messages WHERE session_id = ?');
const deleteMessages = this.db.prepare('DELETE FROM messages WHERE session_id = ?');
const deleteConfig = this.db.prepare('DELETE FROM session_config WHERE session_id = ?');
const transaction = this.db.transaction(() => {
for (const { session_id } of stale) {
deleteStmt.run(session_id);
deleteMessages.run(session_id);
deleteConfig.run(session_id);
}
});
transaction();
@@ -136,6 +148,49 @@ export class SessionStore {
};
}
/** Get a single config value for a session. */
getSessionConfig(sessionId: string, key: string): string | undefined {
const stmt = this.db.prepare(
'SELECT value FROM session_config WHERE session_id = ? AND key = ?',
);
const row = stmt.get(sessionId, key) as { value: string } | undefined;
return row?.value;
}
/** Get all config values for a session. */
getAllSessionConfig(sessionId: string): Record<string, string> {
const stmt = this.db.prepare(
'SELECT key, value FROM session_config WHERE session_id = ?',
);
const rows = stmt.all(sessionId) as Array<{ key: string; value: string }>;
const result: Record<string, string> = {};
for (const row of rows) {
result[row.key] = row.value;
}
return result;
}
/** Set a config value for a session. Upserts (INSERT OR REPLACE). */
setSessionConfig(sessionId: string, key: string, value: string): void {
this.db.prepare(
'INSERT OR REPLACE INTO session_config (session_id, key, value) VALUES (?, ?, ?)',
).run(sessionId, key, value);
}
/** Delete a config value for a session. */
deleteSessionConfig(sessionId: string, key: string): void {
this.db.prepare(
'DELETE FROM session_config WHERE session_id = ? AND key = ?',
).run(sessionId, key);
}
/** Delete all config for a session (used when session is cleared/pruned). */
clearSessionConfig(sessionId: string): void {
this.db.prepare(
'DELETE FROM session_config WHERE session_id = ?',
).run(sessionId);
}
close(): void {
this.db.close();
}