From 84e197b3dcebcef8fb4c39451b35f7f44af6cd1c Mon Sep 17 00:00:00 2001 From: nyk <93952610+0xNyk@users.noreply.github.com> Date: Wed, 11 Mar 2026 22:06:28 +0700 Subject: [PATCH] fix: dynamic coordinator routing + boxed doctor parsing (#279) * fix: route coordinator sends to live sessions and parse boxed doctor output * feat: make coordinator routing user-configurable and deployment-agnostic * feat: add coordinator target dropdown in settings * feat(settings): preview live coordinator routing resolution --- src/app/api/chat/messages/route.ts | 58 ++++-- src/app/api/settings/route.ts | 7 + src/components/panels/settings-panel.tsx | 191 +++++++++++++++--- src/lib/__tests__/coordinator-routing.test.ts | 124 ++++++++++++ src/lib/__tests__/openclaw-doctor.test.ts | 22 ++ src/lib/coordinator-routing.ts | 155 ++++++++++++++ src/lib/openclaw-doctor.ts | 5 +- 7 files changed, 515 insertions(+), 47 deletions(-) create mode 100644 src/lib/__tests__/coordinator-routing.test.ts create mode 100644 src/lib/coordinator-routing.ts diff --git a/src/app/api/chat/messages/route.ts b/src/app/api/chat/messages/route.ts index 89a6f5f..4c6263f 100644 --- a/src/app/api/chat/messages/route.ts +++ b/src/app/api/chat/messages/route.ts @@ -7,6 +7,7 @@ import { requireRole } from '@/lib/auth' import { logger } from '@/lib/logger' import { scanForInjection, sanitizeForPrompt } from '@/lib/injection-guard' import { callOpenClawGateway } from '@/lib/openclaw-gateway' +import { resolveCoordinatorDeliveryTarget } from '@/lib/coordinator-routing' type ForwardInfo = { attempted: boolean @@ -415,35 +416,54 @@ export async function POST(request: NextRequest) { .prepare('SELECT * FROM agents WHERE lower(name) = lower(?) AND workspace_id = ?') .get(to, workspaceId) as any - // Use explicit session key from caller if provided, then DB, then on-disk lookup - let sessionKey: string | null = typeof body.sessionKey === 'string' && body.sessionKey + const explicitSessionKey = typeof body.sessionKey === 'string' && body.sessionKey ? body.sessionKey - : agent?.session_key || null + : null + const sessions = getAllGatewaySessions() + const isCoordinatorSend = String(to).toLowerCase() === COORDINATOR_AGENT.toLowerCase() + const allAgents = isCoordinatorSend + ? (db + .prepare('SELECT name, session_key, config FROM agents WHERE workspace_id = ?') + .all(workspaceId) as Array<{ name: string; session_key?: string | null; config?: string | null }>) + : [] + const configuredCoordinatorTarget = isCoordinatorSend + ? (db + .prepare("SELECT value FROM settings WHERE key = 'chat.coordinator_target_agent'") + .get() as { value?: string } | undefined)?.value || null + : null + + const coordinatorResolution = resolveCoordinatorDeliveryTarget({ + to: String(to), + coordinatorAgent: COORDINATOR_AGENT, + directAgent: agent + ? { + name: String(agent.name || to), + session_key: typeof agent.session_key === 'string' ? agent.session_key : null, + config: typeof agent.config === 'string' ? agent.config : null, + } + : null, + allAgents, + sessions, + explicitSessionKey, + configuredCoordinatorTarget, + }) + + // Use explicit session key from caller if provided, then DB, then on-disk lookup + let sessionKey: string | null = coordinatorResolution.sessionKey // Fallback: derive session from on-disk gateway session stores if (!sessionKey) { - const sessions = getAllGatewaySessions() const match = sessions.find( - (s) => s.agent.toLowerCase() === String(to).toLowerCase() + (s) => + s.agent.toLowerCase() === String(to).toLowerCase() || + s.agent.toLowerCase() === coordinatorResolution.deliveryName.toLowerCase() || + s.agent.toLowerCase() === String(coordinatorResolution.openclawAgentId || '').toLowerCase() ) sessionKey = match?.key || match?.sessionId || null } // Prefer configured openclawId when present, fallback to normalized name - let openclawAgentId: string | null = null - if (agent?.config) { - try { - const cfg = JSON.parse(agent.config) - if (cfg?.openclawId && typeof cfg.openclawId === 'string') { - openclawAgentId = cfg.openclawId - } - } catch { - // ignore parse issues - } - } - if (!openclawAgentId && typeof to === 'string') { - openclawAgentId = to.toLowerCase().replace(/\s+/g, '-') - } + let openclawAgentId: string | null = coordinatorResolution.openclawAgentId if (!sessionKey && !openclawAgentId) { forwardInfo.reason = 'no_active_session' diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts index c8cd73c..4530b7c 100644 --- a/src/app/api/settings/route.ts +++ b/src/app/api/settings/route.ts @@ -29,6 +29,13 @@ const settingDefinitions: Record() + for (const agent of out) { + const key = agent.openclawId || agent.name.toLowerCase() + if (!unique.has(key)) unique.set(key, agent) + } + + return Array.from(unique.values()).sort((a, b) => { + if (a.isDefault !== b.isDefault) return a.isDefault ? -1 : 1 + return a.name.localeCompare(b.name) + }) +} + const categoryLabels: Record = { general: { label: 'General', icon: 'āš™', description: 'Core Mission Control settings' }, security: { label: 'Security', icon: 'šŸ”‘', description: 'API key management and security settings' }, retention: { label: 'Data Retention', icon: 'šŸ—„', description: 'How long data is kept before cleanup' }, + chat: { label: 'Chat', icon: 'šŸ’¬', description: 'Coordinator routing and chat behavior settings' }, gateway: { label: 'Gateway', icon: 'šŸ”Œ', description: 'OpenClaw gateway connection settings' }, profiles: { label: 'Security Profiles', icon: 'shield', description: 'Hook profile controls security scanning strictness' }, custom: { label: 'Custom', icon: 'šŸ”§', description: 'User-defined settings' }, } -const categoryOrder = ['general', 'security', 'profiles', 'retention', 'gateway', 'custom'] +const categoryOrder = ['general', 'security', 'profiles', 'retention', 'chat', 'gateway', 'custom'] // Dropdown options for subscription plan settings const subscriptionDropdowns: Record = { @@ -79,6 +125,8 @@ export function SettingsPanel() { const [showSecurityScan, setShowSecurityScan] = useState(false) const [hookProfile, setHookProfile] = useState('standard') const [hookProfileSaving, setHookProfileSaving] = useState(false) + const [coordinatorTargetAgents, setCoordinatorTargetAgents] = useState([]) + const [coordinatorSessions, setCoordinatorSessions] = useState([]) // Replay onboarding state const [replayingOnboarding, setReplayingOnboarding] = useState(false) @@ -104,6 +152,36 @@ export function SettingsPanel() { setTimeout(() => setFeedback(null), 3000) } + const getCoordinatorResolutionPreview = useCallback((configuredTarget: string) => { + const allAgents: CoordinatorAgentRecord[] = coordinatorTargetAgents.map(agent => ({ + name: agent.name, + session_key: agent.sessionKey, + config: agent.configRaw, + })) + const directAgent = allAgents.find(agent => agent.name.toLowerCase() === COORDINATOR_AGENT) || null + const gatewaySessions = coordinatorSessions.filter(session => (session.source || 'gateway') === 'gateway') + + const resolved = resolveCoordinatorDeliveryTarget({ + to: COORDINATOR_AGENT, + coordinatorAgent: COORDINATOR_AGENT, + directAgent, + allAgents, + sessions: gatewaySessions, + configuredCoordinatorTarget: configuredTarget || null, + }) + + const viaLabel: Record = { + configured: 'configured target', + default: 'default agent', + main_session: 'live :main session', + direct: 'coordinator record', + fallback: 'fallback', + } + + const targetLabel = `${resolved.deliveryName}${resolved.openclawAgentId ? ` (${resolved.openclawAgentId})` : ''}` + return `Resolves now to ${targetLabel} via ${viaLabel[resolved.resolvedBy] || resolved.resolvedBy}.` + }, [coordinatorTargetAgents, coordinatorSessions]) + const fetchSettings = useCallback(async () => { try { const res = await fetch('/api/settings') @@ -126,6 +204,45 @@ export function SettingsPanel() { // Load hook profile from settings const hpSetting = (data.settings || []).find((s: Setting) => s.key === 'hook_profile') if (hpSetting) setHookProfile(hpSetting.value) + + // Load agent options for coordinator routing dropdown + try { + const agentsRes = await fetch('/api/agents?limit=200') + if (agentsRes.ok) { + const agentsData = await agentsRes.json() + setCoordinatorTargetAgents(parseCoordinatorTargetAgents(agentsData.agents || [])) + } + } catch { + // non-critical + } + + // Load live sessions to preview coordinator routing resolution + try { + const sessionsRes = await fetch('/api/sessions') + if (sessionsRes.ok) { + const sessionsData = await sessionsRes.json() + const mapped: CoordinatorSession[] = Array.isArray(sessionsData.sessions) + ? sessionsData.sessions.map((session: any) => ({ + key: String(session?.key || ''), + agent: String(session?.agent || ''), + source: typeof session?.source === 'string' ? session.source : undefined, + sessionId: String(session?.id || session?.key || ''), + updatedAt: Number(session?.lastActivity || session?.startTime || 0), + chatType: String(session?.kind || 'unknown'), + channel: String(session?.channel || ''), + model: String(session?.model || ''), + totalTokens: 0, + inputTokens: 0, + outputTokens: 0, + contextTokens: 0, + active: Boolean(session?.active), + })).filter((session: CoordinatorSession) => session.key && session.agent) + : [] + setCoordinatorSessions(mapped) + } + } catch { + // non-critical + } } catch { setError('Failed to load settings') } finally { @@ -735,7 +852,19 @@ export function SettingsPanel() { const isChanged = edits[setting.key] !== undefined && edits[setting.key] !== setting.value const isBooleanish = setting.value === 'true' || setting.value === 'false' const isNumeric = /^\d+$/.test(setting.value) - const dropdownOptions = subscriptionDropdowns[setting.key] + const coordinatorTargetOptions = setting.key === 'chat.coordinator_target_agent' + ? [ + { label: 'Auto (default/main-session fallback)', value: '' }, + ...coordinatorTargetAgents.map(agent => ({ + label: `${agent.name}${agent.isDefault ? ' (default)' : ''} — ${agent.openclawId}`, + value: agent.openclawId, + })), + ] + : null + const dropdownOptions = coordinatorTargetOptions || subscriptionDropdowns[setting.key] + const coordinatorPreview = setting.key === 'chat.coordinator_target_agent' + ? getCoordinatorResolutionPreview(currentValue) + : null const shortKey = setting.key.split('.').pop() || setting.key return ( @@ -760,18 +889,22 @@ export function SettingsPanel() {

{setting.key}

-
- {dropdownOptions ? ( - - ) : isBooleanish ? ( +
+
+ {dropdownOptions ? ( + + ) : isBooleanish ? ( + {!setting.is_default && ( + + )} +
+ {coordinatorPreview && ( +

{coordinatorPreview}

)}
diff --git a/src/lib/__tests__/coordinator-routing.test.ts b/src/lib/__tests__/coordinator-routing.test.ts new file mode 100644 index 0000000..e9aa03d --- /dev/null +++ b/src/lib/__tests__/coordinator-routing.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest' +import { resolveCoordinatorDeliveryTarget, type CoordinatorAgentRecord } from '@/lib/coordinator-routing' +import type { GatewaySession } from '@/lib/sessions' + +function mkSession(agent: string, key: string): GatewaySession { + return { + key, + agent, + sessionId: `${agent}-session`, + updatedAt: Date.now(), + chatType: 'direct', + channel: 'test', + model: 'test-model', + totalTokens: 0, + inputTokens: 0, + outputTokens: 0, + contextTokens: 0, + active: true, + } +} + +describe('resolveCoordinatorDeliveryTarget', () => { + it('returns direct resolution when target agent exists', () => { + const directAgent: CoordinatorAgentRecord = { + name: 'dev', + session_key: 'agent:dev:main', + config: JSON.stringify({ openclawId: 'dev' }), + } + + const resolved = resolveCoordinatorDeliveryTarget({ + to: 'dev', + coordinatorAgent: 'Coordinator', + directAgent, + allAgents: [], + sessions: [mkSession('dev', 'agent:dev:main')], + }) + + expect(resolved).toEqual({ + deliveryName: 'dev', + sessionKey: 'agent:dev:main', + openclawAgentId: 'dev', + resolvedBy: 'direct', + }) + }) + + it('resolves coordinator to explicitly configured target when present', () => { + const allAgents: CoordinatorAgentRecord[] = [ + { name: 'jarv', config: JSON.stringify({ openclawId: 'jarv' }) }, + { name: 'dev', config: JSON.stringify({ isDefault: true, openclawId: 'dev' }) }, + ] + + const resolved = resolveCoordinatorDeliveryTarget({ + to: 'Coordinator', + coordinatorAgent: 'Coordinator', + directAgent: null, + allAgents, + sessions: [mkSession('jarv', 'agent:jarv:main')], + configuredCoordinatorTarget: 'jarv', + }) + + expect(resolved).toEqual({ + deliveryName: 'jarv', + sessionKey: 'agent:jarv:main', + openclawAgentId: 'jarv', + resolvedBy: 'configured', + }) + }) + + it('resolves coordinator to default agent when no explicit target is configured', () => { + const allAgents: CoordinatorAgentRecord[] = [ + { name: 'jarv', config: JSON.stringify({ openclawId: 'jarv' }) }, + { name: 'dev', config: JSON.stringify({ isDefault: true, openclawId: 'dev' }) }, + ] + + const resolved = resolveCoordinatorDeliveryTarget({ + to: 'Coordinator', + coordinatorAgent: 'Coordinator', + directAgent: null, + allAgents, + sessions: [mkSession('dev', 'agent:dev:main')], + }) + + expect(resolved).toEqual({ + deliveryName: 'dev', + sessionKey: 'agent:dev:main', + openclawAgentId: 'dev', + resolvedBy: 'default', + }) + }) + + it('resolves coordinator to first live main session when no default agent exists', () => { + const resolved = resolveCoordinatorDeliveryTarget({ + to: 'Coordinator', + coordinatorAgent: 'Coordinator', + directAgent: null, + allAgents: [{ name: 'admin', config: JSON.stringify({ openclawId: 'admin' }) }], + sessions: [mkSession('jarv', 'agent:jarv:main')], + }) + + expect(resolved).toEqual({ + deliveryName: 'jarv', + sessionKey: 'agent:jarv:main', + openclawAgentId: 'jarv', + resolvedBy: 'main_session', + }) + }) + + it('falls back to normalized destination when nothing else matches', () => { + const resolved = resolveCoordinatorDeliveryTarget({ + to: 'Coordinator Team', + coordinatorAgent: 'Coordinator', + directAgent: null, + allAgents: [], + sessions: [], + }) + + expect(resolved).toEqual({ + deliveryName: 'Coordinator Team', + sessionKey: null, + openclawAgentId: 'coordinator-team', + resolvedBy: 'fallback', + }) + }) +}) diff --git a/src/lib/__tests__/openclaw-doctor.test.ts b/src/lib/__tests__/openclaw-doctor.test.ts index 9188866..cd18aef 100644 --- a/src/lib/__tests__/openclaw-doctor.test.ts +++ b/src/lib/__tests__/openclaw-doctor.test.ts @@ -89,6 +89,28 @@ Run "openclaw doctor --fix" to apply changes. expect(result.raw).not.toContain('Multiple state directories detected') }) + it('parses state integrity blocks when lines are prefixed by box-drawing gutters', () => { + const result = parseOpenClawDoctorOutput(` +ā”Œ OpenClaw doctor +│ +ā—‡ State integrity +│ - Multiple state directories detected. This can split session history. +│ - $OPENCLAW_HOME/.openclaw +│ - /home/nefes/.openclaw +│ Active state dir: $OPENCLAW_HOME +│ - Found 11 orphan transcript file(s) in $OPENCLAW_HOME/agents/jarv/sessions. +Run "openclaw doctor --fix" to apply changes. +`, 0, { stateDir: '/home/openclaw/.openclaw' }) + + expect(result.level).toBe('warning') + expect(result.category).toBe('state') + expect(result.issues).toEqual([ + 'Found 11 orphan transcript file(s) in $OPENCLAW_HOME/agents/jarv/sessions.', + ]) + expect(result.raw).not.toContain('/home/nefes/.openclaw') + expect(result.raw).not.toContain('Multiple state directories detected') + }) + it('marks clean output as healthy', () => { const result = parseOpenClawDoctorOutput('OK: configuration valid', 0) diff --git a/src/lib/coordinator-routing.ts b/src/lib/coordinator-routing.ts new file mode 100644 index 0000000..81a19a8 --- /dev/null +++ b/src/lib/coordinator-routing.ts @@ -0,0 +1,155 @@ +import type { GatewaySession } from './sessions' + +export interface CoordinatorAgentRecord { + name: string + session_key?: string | null + config?: string | null +} + +export interface ResolvedCoordinatorTarget { + deliveryName: string + sessionKey: string | null + openclawAgentId: string | null + resolvedBy: 'direct' | 'configured' | 'default' | 'main_session' | 'fallback' +} + +function normalizeName(value: string | null | undefined): string { + return String(value || '').trim().toLowerCase() +} + +function normalizeOpenClawId(value: string | null | undefined): string { + return normalizeName(value).replace(/\s+/g, '-') +} + +function parseConfig(raw: string | null | undefined): Record { + if (!raw) return {} + try { + const parsed = JSON.parse(raw) + return parsed && typeof parsed === 'object' ? parsed : {} + } catch { + return {} + } +} + +function getConfigOpenClawId(agent: CoordinatorAgentRecord): string | null { + const parsed = parseConfig(agent.config) + return typeof parsed.openclawId === 'string' && parsed.openclawId.trim() + ? parsed.openclawId.trim() + : null +} + +function getConfigIsDefault(agent: CoordinatorAgentRecord): boolean { + const parsed = parseConfig(agent.config) + return parsed.isDefault === true +} + +function findSessionForAgent( + agent: CoordinatorAgentRecord, + sessions: GatewaySession[], +): GatewaySession | undefined { + const name = normalizeName(agent.name) + const openclawId = normalizeOpenClawId(getConfigOpenClawId(agent) || agent.name) + return sessions.find((session) => { + const sessionAgent = normalizeName(session.agent) + return sessionAgent === name || sessionAgent === openclawId + }) +} + +function resolveConfiguredCoordinatorTarget( + preferredTarget: string, + allAgents: CoordinatorAgentRecord[], + sessions: GatewaySession[], +): CoordinatorAgentRecord | null { + const wanted = normalizeName(preferredTarget) + if (!wanted) return null + + return allAgents.find((agent) => { + const byName = normalizeName(agent.name) === wanted + const byOpenClawId = normalizeOpenClawId(getConfigOpenClawId(agent) || agent.name) === wanted + const session = findSessionForAgent(agent, sessions) + const bySessionAgent = session ? normalizeName(session.agent) === wanted : false + return byName || byOpenClawId || bySessionAgent + }) || null +} + +export function resolveCoordinatorDeliveryTarget(params: { + to: string + coordinatorAgent: string + directAgent: CoordinatorAgentRecord | null + allAgents: CoordinatorAgentRecord[] + sessions: GatewaySession[] + explicitSessionKey?: string | null + configuredCoordinatorTarget?: string | null +}): ResolvedCoordinatorTarget { + const normalizedTo = normalizeName(params.to) + const normalizedCoordinatorAgent = normalizeName(params.coordinatorAgent) + const explicitSessionKey = params.explicitSessionKey?.trim() || null + + const buildResult = ( + agent: CoordinatorAgentRecord, + resolvedBy: ResolvedCoordinatorTarget['resolvedBy'], + ): ResolvedCoordinatorTarget => { + const openclawAgentId = getConfigOpenClawId(agent) || normalizeOpenClawId(agent.name) + const sessionKey = + explicitSessionKey || + agent.session_key?.trim() || + findSessionForAgent(agent, params.sessions)?.key || + null + + return { + deliveryName: agent.name, + sessionKey, + openclawAgentId, + resolvedBy, + } + } + + if (normalizedTo === normalizedCoordinatorAgent) { + const configuredTarget = (params.configuredCoordinatorTarget || '').trim() + if (configuredTarget) { + const configuredAgent = resolveConfiguredCoordinatorTarget(configuredTarget, params.allAgents, params.sessions) + if (configuredAgent) { + return buildResult(configuredAgent, 'configured') + } + } + + const defaultAgent = params.allAgents.find(getConfigIsDefault) + if (defaultAgent) { + return buildResult(defaultAgent, 'default') + } + + const mainSession = params.sessions.find((session) => /:main$/i.test(session.key)) + if (mainSession) { + const matchingAgent = params.allAgents.find((agent) => { + const openclawId = normalizeOpenClawId(getConfigOpenClawId(agent) || agent.name) + const agentName = normalizeName(agent.name) + const sessionAgent = normalizeName(mainSession.agent) + return sessionAgent === agentName || sessionAgent === openclawId + }) + + return { + deliveryName: matchingAgent?.name || mainSession.agent, + sessionKey: explicitSessionKey || mainSession.key || null, + openclawAgentId: + getConfigOpenClawId(matchingAgent || { name: mainSession.agent }) || + normalizeOpenClawId(mainSession.agent), + resolvedBy: 'main_session', + } + } + + if (params.directAgent) { + return buildResult(params.directAgent, 'direct') + } + } + + if (params.directAgent) { + return buildResult(params.directAgent, 'direct') + } + + return { + deliveryName: params.to, + sessionKey: explicitSessionKey, + openclawAgentId: normalizeOpenClawId(params.to), + resolvedBy: 'fallback', + } +} diff --git a/src/lib/openclaw-doctor.ts b/src/lib/openclaw-doctor.ts index b01a362..5ed800c 100644 --- a/src/lib/openclaw-doctor.ts +++ b/src/lib/openclaw-doctor.ts @@ -14,7 +14,10 @@ export interface OpenClawDoctorStatus { } function normalizeLine(line: string): string { - return line.replace(/\u001b\[[0-9;]*m/g, '').trim() + return line + .replace(/\u001b\[[0-9;]*m/g, '') + .replace(/^[\sā”‚ā”ƒā•‘ā”†ā”Šā•Žā•]+/, '') + .trim() } function isSessionAgingLine(line: string): boolean {