feat(M4-009,M4-010,M4-011): routing rules CRUD, per-user overrides, agent capabilities #320
@@ -13,12 +13,18 @@ import { Server, Socket } from 'socket.io';
|
|||||||
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
||||||
import type { Auth } from '@mosaic/auth';
|
import type { Auth } from '@mosaic/auth';
|
||||||
import type { Brain } from '@mosaic/brain';
|
import type { Brain } from '@mosaic/brain';
|
||||||
import type { SetThinkingPayload, SlashCommandPayload, SystemReloadPayload } from '@mosaic/types';
|
import type {
|
||||||
|
SetThinkingPayload,
|
||||||
|
SlashCommandPayload,
|
||||||
|
SystemReloadPayload,
|
||||||
|
RoutingDecisionInfo,
|
||||||
|
} from '@mosaic/types';
|
||||||
import { AgentService, type ConversationHistoryMessage } from '../agent/agent.service.js';
|
import { AgentService, type ConversationHistoryMessage } from '../agent/agent.service.js';
|
||||||
import { AUTH } from '../auth/auth.tokens.js';
|
import { AUTH } from '../auth/auth.tokens.js';
|
||||||
import { BRAIN } from '../brain/brain.tokens.js';
|
import { BRAIN } from '../brain/brain.tokens.js';
|
||||||
import { CommandRegistryService } from '../commands/command-registry.service.js';
|
import { CommandRegistryService } from '../commands/command-registry.service.js';
|
||||||
import { CommandExecutorService } from '../commands/command-executor.service.js';
|
import { CommandExecutorService } from '../commands/command-executor.service.js';
|
||||||
|
import { RoutingEngineService } from '../agent/routing/routing-engine.service.js';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { ChatSocketMessageDto } from './chat.dto.js';
|
import { ChatSocketMessageDto } from './chat.dto.js';
|
||||||
import { validateSocketSession } from './chat.gateway-auth.js';
|
import { validateSocketSession } from './chat.gateway-auth.js';
|
||||||
@@ -33,8 +39,16 @@ interface ClientSession {
|
|||||||
toolCalls: Array<{ toolCallId: string; toolName: string; args: unknown; isError: boolean }>;
|
toolCalls: Array<{ toolCallId: string; toolName: string; args: unknown; isError: boolean }>;
|
||||||
/** Tool calls in-flight (started but not ended yet). */
|
/** Tool calls in-flight (started but not ended yet). */
|
||||||
pendingToolCalls: Map<string, { toolName: string; args: unknown }>;
|
pendingToolCalls: Map<string, { toolName: string; args: unknown }>;
|
||||||
|
/** Last routing decision made for this session (M4-008) */
|
||||||
|
lastRoutingDecision?: RoutingDecisionInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-conversation model overrides set via /model command (M4-007).
|
||||||
|
* Keyed by conversationId, value is the model name to use.
|
||||||
|
*/
|
||||||
|
const modelOverrides = new Map<string, string>();
|
||||||
|
|
||||||
@WebSocketGateway({
|
@WebSocketGateway({
|
||||||
cors: {
|
cors: {
|
||||||
origin: process.env['GATEWAY_CORS_ORIGIN'] ?? 'http://localhost:3000',
|
origin: process.env['GATEWAY_CORS_ORIGIN'] ?? 'http://localhost:3000',
|
||||||
@@ -54,6 +68,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
@Inject(BRAIN) private readonly brain: Brain,
|
@Inject(BRAIN) private readonly brain: Brain,
|
||||||
@Inject(CommandRegistryService) private readonly commandRegistry: CommandRegistryService,
|
@Inject(CommandRegistryService) private readonly commandRegistry: CommandRegistryService,
|
||||||
@Inject(CommandExecutorService) private readonly commandExecutor: CommandExecutorService,
|
@Inject(CommandExecutorService) private readonly commandExecutor: CommandExecutorService,
|
||||||
|
@Inject(RoutingEngineService) private readonly routingEngine: RoutingEngineService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
afterInit(): void {
|
afterInit(): void {
|
||||||
@@ -97,15 +112,50 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
this.logger.log(`Message from ${client.id} in conversation ${conversationId}`);
|
this.logger.log(`Message from ${client.id} in conversation ${conversationId}`);
|
||||||
|
|
||||||
// Ensure agent session exists for this conversation
|
// Ensure agent session exists for this conversation
|
||||||
|
let sessionRoutingDecision: RoutingDecisionInfo | undefined;
|
||||||
try {
|
try {
|
||||||
let agentSession = this.agentService.getSession(conversationId);
|
let agentSession = this.agentService.getSession(conversationId);
|
||||||
if (!agentSession) {
|
if (!agentSession) {
|
||||||
// When resuming an existing conversation, load prior messages to inject as context (M1-004)
|
// When resuming an existing conversation, load prior messages to inject as context (M1-004)
|
||||||
const conversationHistory = await this.loadConversationHistory(conversationId, userId);
|
const conversationHistory = await this.loadConversationHistory(conversationId, userId);
|
||||||
|
|
||||||
|
// Determine provider/model via routing engine or per-session /model override (M4-012 / M4-007)
|
||||||
|
let resolvedProvider = data.provider;
|
||||||
|
let resolvedModelId = data.modelId;
|
||||||
|
|
||||||
|
const modelOverride = modelOverrides.get(conversationId);
|
||||||
|
if (modelOverride) {
|
||||||
|
// /model override bypasses routing engine (M4-007)
|
||||||
|
resolvedModelId = modelOverride;
|
||||||
|
this.logger.log(
|
||||||
|
`Using /model override "${modelOverride}" for conversation=${conversationId}`,
|
||||||
|
);
|
||||||
|
} else if (!resolvedProvider && !resolvedModelId) {
|
||||||
|
// No explicit provider/model from client — use routing engine (M4-012)
|
||||||
|
try {
|
||||||
|
const routingDecision = await this.routingEngine.resolve(data.content, userId);
|
||||||
|
resolvedProvider = routingDecision.provider;
|
||||||
|
resolvedModelId = routingDecision.model;
|
||||||
|
sessionRoutingDecision = {
|
||||||
|
model: routingDecision.model,
|
||||||
|
provider: routingDecision.provider,
|
||||||
|
ruleName: routingDecision.ruleName,
|
||||||
|
reason: routingDecision.reason,
|
||||||
|
};
|
||||||
|
this.logger.log(
|
||||||
|
`Routing decision for conversation=${conversationId}: ${routingDecision.provider}/${routingDecision.model} (rule="${routingDecision.ruleName}")`,
|
||||||
|
);
|
||||||
|
} catch (routingErr) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Routing engine failed for conversation=${conversationId}, using defaults`,
|
||||||
|
routingErr instanceof Error ? routingErr.message : String(routingErr),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
agentSession = await this.agentService.createSession(conversationId, {
|
agentSession = await this.agentService.createSession(conversationId, {
|
||||||
provider: data.provider,
|
provider: resolvedProvider,
|
||||||
modelId: data.modelId,
|
modelId: resolvedModelId,
|
||||||
agentConfigId: data.agentId,
|
agentConfigId: data.agentId,
|
||||||
userId,
|
userId,
|
||||||
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
|
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
|
||||||
@@ -167,18 +217,23 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
this.relayEvent(client, conversationId, event);
|
this.relayEvent(client, conversationId, event);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Preserve routing decision from the existing client session if we didn't get a new one
|
||||||
|
const prevClientSession = this.clientSessions.get(client.id);
|
||||||
|
const routingDecisionToStore = sessionRoutingDecision ?? prevClientSession?.lastRoutingDecision;
|
||||||
|
|
||||||
this.clientSessions.set(client.id, {
|
this.clientSessions.set(client.id, {
|
||||||
conversationId,
|
conversationId,
|
||||||
cleanup,
|
cleanup,
|
||||||
assistantText: '',
|
assistantText: '',
|
||||||
toolCalls: [],
|
toolCalls: [],
|
||||||
pendingToolCalls: new Map(),
|
pendingToolCalls: new Map(),
|
||||||
|
lastRoutingDecision: routingDecisionToStore,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track channel connection
|
// Track channel connection
|
||||||
this.agentService.addChannel(conversationId, `websocket:${client.id}`);
|
this.agentService.addChannel(conversationId, `websocket:${client.id}`);
|
||||||
|
|
||||||
// Send session info so the client knows the model/provider
|
// Send session info so the client knows the model/provider (M4-008: include routing decision)
|
||||||
{
|
{
|
||||||
const agentSession = this.agentService.getSession(conversationId);
|
const agentSession = this.agentService.getSession(conversationId);
|
||||||
if (agentSession) {
|
if (agentSession) {
|
||||||
@@ -189,6 +244,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
modelId: agentSession.modelId,
|
modelId: agentSession.modelId,
|
||||||
thinkingLevel: piSession.thinkingLevel,
|
thinkingLevel: piSession.thinkingLevel,
|
||||||
availableThinkingLevels: piSession.getAvailableThinkingLevels(),
|
availableThinkingLevels: piSession.getAvailableThinkingLevels(),
|
||||||
|
...(routingDecisionToStore ? { routingDecision: routingDecisionToStore } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -263,6 +319,28 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
this.logger.log('Broadcasted system:reload to all connected clients');
|
this.logger.log('Broadcasted system:reload to all connected clients');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a per-conversation model override (M4-007).
|
||||||
|
* When set, the routing engine is bypassed and the specified model is used.
|
||||||
|
* Pass null to clear the override and resume automatic routing.
|
||||||
|
*/
|
||||||
|
setModelOverride(conversationId: string, modelName: string | null): void {
|
||||||
|
if (modelName) {
|
||||||
|
modelOverrides.set(conversationId, modelName);
|
||||||
|
this.logger.log(`Model override set: conversation=${conversationId} model="${modelName}"`);
|
||||||
|
} else {
|
||||||
|
modelOverrides.delete(conversationId);
|
||||||
|
this.logger.log(`Model override cleared: conversation=${conversationId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the active model override for a conversation, or undefined if none.
|
||||||
|
*/
|
||||||
|
getModelOverride(conversationId: string): string | undefined {
|
||||||
|
return modelOverrides.get(conversationId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure a conversation record exists in the DB.
|
* Ensure a conversation record exists in the DB.
|
||||||
* Creates it if absent — safe to call concurrently since a duplicate insert
|
* Creates it if absent — safe to call concurrently since a duplicate insert
|
||||||
|
|||||||
@@ -403,6 +403,7 @@ export function TuiApp({
|
|||||||
providerName={socket.providerName}
|
providerName={socket.providerName}
|
||||||
thinkingLevel={socket.thinkingLevel}
|
thinkingLevel={socket.thinkingLevel}
|
||||||
conversationId={socket.conversationId}
|
conversationId={socket.conversationId}
|
||||||
|
routingDecision={socket.routingDecision}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
|
import type { RoutingDecisionInfo } from '@mosaic/types';
|
||||||
import type { TokenUsage } from '../hooks/use-socket.js';
|
import type { TokenUsage } from '../hooks/use-socket.js';
|
||||||
import type { GitInfo } from '../hooks/use-git-info.js';
|
import type { GitInfo } from '../hooks/use-git-info.js';
|
||||||
|
|
||||||
@@ -12,6 +13,8 @@ export interface BottomBarProps {
|
|||||||
providerName: string | null;
|
providerName: string | null;
|
||||||
thinkingLevel: string;
|
thinkingLevel: string;
|
||||||
conversationId: string | undefined;
|
conversationId: string | undefined;
|
||||||
|
/** Routing decision info for transparency display (M4-008) */
|
||||||
|
routingDecision?: RoutingDecisionInfo | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTokens(n: number): string {
|
function formatTokens(n: number): string {
|
||||||
@@ -38,6 +41,7 @@ export function BottomBar({
|
|||||||
providerName,
|
providerName,
|
||||||
thinkingLevel,
|
thinkingLevel,
|
||||||
conversationId,
|
conversationId,
|
||||||
|
routingDecision,
|
||||||
}: BottomBarProps) {
|
}: BottomBarProps) {
|
||||||
const gatewayStatus = connected ? 'Connected' : connecting ? 'Connecting…' : 'Disconnected';
|
const gatewayStatus = connected ? 'Connected' : connecting ? 'Connecting…' : 'Disconnected';
|
||||||
const gatewayColor = connected ? 'green' : connecting ? 'yellow' : 'red';
|
const gatewayColor = connected ? 'green' : connecting ? 'yellow' : 'red';
|
||||||
@@ -120,6 +124,15 @@ export function BottomBar({
|
|||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Line 4: routing transparency (M4-008) — only shown when a routing decision is available */}
|
||||||
|
{routingDecision && (
|
||||||
|
<Box>
|
||||||
|
<Text dimColor>
|
||||||
|
Routed: {routingDecision.model} ({routingDecision.reason})
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type {
|
|||||||
CommandManifestPayload,
|
CommandManifestPayload,
|
||||||
SlashCommandResultPayload,
|
SlashCommandResultPayload,
|
||||||
SystemReloadPayload,
|
SystemReloadPayload,
|
||||||
|
RoutingDecisionInfo,
|
||||||
} from '@mosaic/types';
|
} from '@mosaic/types';
|
||||||
import { commandRegistry } from '../commands/index.js';
|
import { commandRegistry } from '../commands/index.js';
|
||||||
|
|
||||||
@@ -66,6 +67,8 @@ export interface UseSocketReturn {
|
|||||||
providerName: string | null;
|
providerName: string | null;
|
||||||
thinkingLevel: string;
|
thinkingLevel: string;
|
||||||
availableThinkingLevels: string[];
|
availableThinkingLevels: string[];
|
||||||
|
/** Last routing decision received from the gateway (M4-008) */
|
||||||
|
routingDecision: RoutingDecisionInfo | null;
|
||||||
sendMessage: (content: string) => void;
|
sendMessage: (content: string) => void;
|
||||||
addSystemMessage: (content: string) => void;
|
addSystemMessage: (content: string) => void;
|
||||||
setThinkingLevel: (level: string) => void;
|
setThinkingLevel: (level: string) => void;
|
||||||
@@ -109,6 +112,7 @@ export function useSocket(opts: UseSocketOptions): UseSocketReturn {
|
|||||||
const [providerName, setProviderName] = useState<string | null>(null);
|
const [providerName, setProviderName] = useState<string | null>(null);
|
||||||
const [thinkingLevel, setThinkingLevelState] = useState<string>('off');
|
const [thinkingLevel, setThinkingLevelState] = useState<string>('off');
|
||||||
const [availableThinkingLevels, setAvailableThinkingLevels] = useState<string[]>([]);
|
const [availableThinkingLevels, setAvailableThinkingLevels] = useState<string[]>([]);
|
||||||
|
const [routingDecision, setRoutingDecision] = useState<RoutingDecisionInfo | null>(null);
|
||||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||||
|
|
||||||
const socketRef = useRef<TypedSocket | null>(null);
|
const socketRef = useRef<TypedSocket | null>(null);
|
||||||
@@ -154,6 +158,10 @@ export function useSocket(opts: UseSocketOptions): UseSocketReturn {
|
|||||||
setModelName(data.modelId);
|
setModelName(data.modelId);
|
||||||
setThinkingLevelState(data.thinkingLevel);
|
setThinkingLevelState(data.thinkingLevel);
|
||||||
setAvailableThinkingLevels(data.availableThinkingLevels);
|
setAvailableThinkingLevels(data.availableThinkingLevels);
|
||||||
|
// Update routing decision if provided (M4-008)
|
||||||
|
if (data.routingDecision) {
|
||||||
|
setRoutingDecision(data.routingDecision);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('agent:start', () => {
|
socket.on('agent:start', () => {
|
||||||
@@ -319,6 +327,7 @@ export function useSocket(opts: UseSocketOptions): UseSocketReturn {
|
|||||||
providerName,
|
providerName,
|
||||||
thinkingLevel,
|
thinkingLevel,
|
||||||
availableThinkingLevels,
|
availableThinkingLevels,
|
||||||
|
routingDecision,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
addSystemMessage,
|
addSystemMessage,
|
||||||
setThinkingLevel,
|
setThinkingLevel,
|
||||||
|
|||||||
@@ -74,6 +74,14 @@ export interface ChatMessagePayload {
|
|||||||
agentId?: string;
|
agentId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Routing decision summary included in session:info for transparency */
|
||||||
|
export interface RoutingDecisionInfo {
|
||||||
|
model: string;
|
||||||
|
provider: string;
|
||||||
|
ruleName: string;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** Session info pushed when session is created or model changes */
|
/** Session info pushed when session is created or model changes */
|
||||||
export interface SessionInfoPayload {
|
export interface SessionInfoPayload {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
@@ -81,6 +89,8 @@ export interface SessionInfoPayload {
|
|||||||
modelId: string;
|
modelId: string;
|
||||||
thinkingLevel: string;
|
thinkingLevel: string;
|
||||||
availableThinkingLevels: string[];
|
availableThinkingLevels: string[];
|
||||||
|
/** Present when automatic routing determined the model for this session */
|
||||||
|
routingDecision?: RoutingDecisionInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Client request to change thinking level */
|
/** Client request to change thinking level */
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type {
|
|||||||
ToolEndPayload,
|
ToolEndPayload,
|
||||||
SessionUsagePayload,
|
SessionUsagePayload,
|
||||||
SessionInfoPayload,
|
SessionInfoPayload,
|
||||||
|
RoutingDecisionInfo,
|
||||||
SetThinkingPayload,
|
SetThinkingPayload,
|
||||||
ErrorPayload,
|
ErrorPayload,
|
||||||
ChatMessagePayload,
|
ChatMessagePayload,
|
||||||
|
|||||||
Reference in New Issue
Block a user