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:
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user