nyk
fix: dynamic coordinator routing + boxed doctor parsing (#279)
cd054b5 unverified
Raw
History Blame Contribute Delete
26.2 kB
import { NextRequest, NextResponse } from 'next/server'
import { getDatabase, db_helpers, Message } from '@/lib/db'
import { runOpenClaw } from '@/lib/command'
import { getAllGatewaySessions } from '@/lib/sessions'
import { eventBus } from '@/lib/event-bus'
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
delivered: boolean
reason?: string
session?: string
runId?: string
}
type ToolEvent = {
name: string
input?: string
output?: string
status?: string
}
type ChatAttachmentInput = {
name?: string
type?: string
dataUrl?: string
}
const COORDINATOR_AGENT =
String(process.env.MC_COORDINATOR_AGENT || process.env.NEXT_PUBLIC_COORDINATOR_AGENT || 'coordinator').trim() ||
'coordinator'
function parseGatewayJson(raw: string): any | null {
const trimmed = String(raw || '').trim()
if (!trimmed) return null
const start = trimmed.indexOf('{')
const end = trimmed.lastIndexOf('}')
if (start < 0 || end < start) return null
try {
return JSON.parse(trimmed.slice(start, end + 1))
} catch {
return null
}
}
function toGatewayAttachments(value: unknown): Array<{ type: 'image'; mimeType: string; fileName?: string; content: string }> | undefined {
if (!Array.isArray(value)) return undefined
const attachments = value.flatMap((entry) => {
const file = entry as ChatAttachmentInput
if (!file || typeof file !== 'object' || typeof file.dataUrl !== 'string') return []
const match = /^data:([^;]+);base64,(.+)$/.exec(file.dataUrl)
if (!match) return []
if (!match[1].startsWith('image/')) return []
return [{
type: 'image' as const,
mimeType: match[1],
fileName: typeof file.name === 'string' ? file.name : undefined,
content: match[2],
}]
})
return attachments.length > 0 ? attachments : undefined
}
function safeParseMetadata(raw: string | null | undefined): any | null {
if (!raw) return null
try {
return JSON.parse(raw)
} catch {
return null
}
}
function createChatReply(
db: ReturnType<typeof getDatabase>,
workspaceId: number,
conversationId: string,
fromAgent: string,
toAgent: string,
content: string,
messageType: 'text' | 'status' | 'tool_call' = 'status',
metadata: Record<string, any> | null = null
) {
const replyInsert = db
.prepare(`
INSERT INTO messages (conversation_id, from_agent, to_agent, content, message_type, metadata, workspace_id)
VALUES (?, ?, ?, ?, ?, ?, ?)
`)
.run(
conversationId,
fromAgent,
toAgent,
content,
messageType,
metadata ? JSON.stringify(metadata) : null,
workspaceId
)
const row = db
.prepare('SELECT * FROM messages WHERE id = ? AND workspace_id = ?')
.get(replyInsert.lastInsertRowid, workspaceId) as Message
eventBus.broadcast('chat.message', {
...row,
metadata: safeParseMetadata(row.metadata),
})
}
function extractReplyText(waitPayload: any): string | null {
if (!waitPayload || typeof waitPayload !== 'object') return null
const directCandidates = [
waitPayload.text,
waitPayload.message,
waitPayload.response,
waitPayload.output,
waitPayload.result,
]
for (const value of directCandidates) {
if (typeof value === 'string' && value.trim()) return value.trim()
}
if (typeof waitPayload.output === 'object' && waitPayload.output) {
const nested = [
waitPayload.output.text,
waitPayload.output.message,
waitPayload.output.content,
]
for (const value of nested) {
if (typeof value === 'string' && value.trim()) return value.trim()
}
}
if (Array.isArray(waitPayload.output)) {
const parts: string[] = []
for (const item of waitPayload.output) {
if (!item || typeof item !== 'object') continue
if (typeof item.text === 'string' && item.text.trim()) parts.push(item.text.trim())
if (item.type === 'message' && Array.isArray(item.content)) {
for (const block of item.content) {
if (!block || typeof block !== 'object') continue
const blockType = String(block.type || '')
if ((blockType === 'text' || blockType === 'output_text' || blockType === 'input_text') && typeof block.text === 'string' && block.text.trim()) {
parts.push(block.text.trim())
}
}
}
}
if (parts.length > 0) return parts.join('\n').slice(0, 8000)
}
return null
}
function normalizeToolEvent(raw: any): ToolEvent | null {
if (!raw || typeof raw !== 'object') return null
const name = String(raw.name || raw.tool || raw.toolName || raw.function || raw.call || '').trim()
if (!name) return null
const inputRaw = raw.input ?? raw.args ?? raw.arguments ?? raw.params
const outputRaw = raw.output ?? raw.result ?? raw.response
const statusRaw =
raw.status ??
(raw.isError === true ? 'error' : undefined) ??
(raw.ok === false ? 'error' : undefined) ??
(raw.success === true ? 'ok' : undefined)
const input =
typeof inputRaw === 'string'
? inputRaw.slice(0, 2000)
: inputRaw !== undefined
? JSON.stringify(inputRaw).slice(0, 2000)
: undefined
const output =
typeof outputRaw === 'string'
? outputRaw.slice(0, 4000)
: outputRaw !== undefined
? JSON.stringify(outputRaw).slice(0, 4000)
: undefined
const status = statusRaw !== undefined ? String(statusRaw).slice(0, 60) : undefined
return { name, input, output, status }
}
function extractToolEvents(waitPayload: any): ToolEvent[] {
if (!waitPayload || typeof waitPayload !== 'object') return []
const candidates = [
waitPayload.toolCalls,
waitPayload.tools,
waitPayload.calls,
waitPayload.events,
waitPayload.output?.toolCalls,
waitPayload.output?.tools,
waitPayload.output?.events,
]
const events: ToolEvent[] = []
for (const list of candidates) {
if (!Array.isArray(list)) continue
for (const item of list) {
const evt = normalizeToolEvent(item)
if (evt) events.push(evt)
if (events.length >= 20) return events
}
}
// OpenAI Responses-style output array
if (Array.isArray(waitPayload.output)) {
for (const item of waitPayload.output) {
if (!item || typeof item !== 'object') continue
const itemType = String(item.type || '').toLowerCase()
if (itemType === 'function_call' || itemType === 'tool_call') {
const evt = normalizeToolEvent({
name: item.name || item.tool_name || item.toolName,
arguments: item.arguments || item.input,
output: item.output || item.result,
status: item.status,
})
if (evt) events.push(evt)
} else if (itemType === 'message' && Array.isArray(item.content)) {
for (const block of item.content) {
const blockType = String(block?.type || '').toLowerCase()
if (blockType === 'tool_use' || blockType === 'tool_call' || blockType === 'function_call') {
const evt = normalizeToolEvent(block)
if (evt) events.push(evt)
}
}
}
if (events.length >= 20) return events
}
}
return events
}
/**
* GET /api/chat/messages - List messages with filters
* Query params: conversation_id, from_agent, to_agent, limit, offset, since
*/
export async function GET(request: NextRequest) {
const auth = requireRole(request, 'viewer')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
try {
const db = getDatabase()
const workspaceId = auth.user.workspace_id ?? 1
const { searchParams } = new URL(request.url)
const conversation_id = searchParams.get('conversation_id')
const from_agent = searchParams.get('from_agent')
const to_agent = searchParams.get('to_agent')
const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200)
const offset = parseInt(searchParams.get('offset') || '0')
const since = searchParams.get('since')
let query = 'SELECT * FROM messages WHERE workspace_id = ?'
const params: any[] = [workspaceId]
if (conversation_id) {
query += ' AND conversation_id = ?'
params.push(conversation_id)
}
if (from_agent) {
query += ' AND from_agent = ?'
params.push(from_agent)
}
if (to_agent) {
query += ' AND to_agent = ?'
params.push(to_agent)
}
if (since) {
query += ' AND created_at > ?'
params.push(parseInt(since))
}
query += ' ORDER BY created_at ASC LIMIT ? OFFSET ?'
params.push(limit, offset)
const messages = db.prepare(query).all(...params) as Message[]
const parsed = messages.map((msg) => ({
...msg,
metadata: safeParseMetadata(msg.metadata),
}))
// Get total count for pagination
let countQuery = 'SELECT COUNT(*) as total FROM messages WHERE workspace_id = ?'
const countParams: any[] = [workspaceId]
if (conversation_id) {
countQuery += ' AND conversation_id = ?'
countParams.push(conversation_id)
}
if (from_agent) {
countQuery += ' AND from_agent = ?'
countParams.push(from_agent)
}
if (to_agent) {
countQuery += ' AND to_agent = ?'
countParams.push(to_agent)
}
if (since) {
countQuery += ' AND created_at > ?'
countParams.push(parseInt(since))
}
const countRow = db.prepare(countQuery).get(...countParams) as { total: number }
return NextResponse.json({ messages: parsed, total: countRow.total, page: Math.floor(offset / limit) + 1, limit })
} catch (error) {
logger.error({ err: error }, 'GET /api/chat/messages error')
return NextResponse.json({ error: 'Failed to fetch messages' }, { status: 500 })
}
}
/**
* POST /api/chat/messages - Send a new message
* Body: { to, content, message_type, conversation_id, metadata }
* Sender identity is always resolved server-side from authenticated user.
*/
export async function POST(request: NextRequest) {
const auth = requireRole(request, 'operator')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
try {
const db = getDatabase()
const workspaceId = auth.user.workspace_id ?? 1
const body = await request.json()
const requestedFrom = typeof body.from === 'string' ? body.from.trim() : ''
const isCoordinatorOverride = requestedFrom.toLowerCase() === COORDINATOR_AGENT.toLowerCase()
const from = isCoordinatorOverride
? COORDINATOR_AGENT
: (auth.user.display_name || auth.user.username || 'system')
const to = body.to ? (body.to as string).trim() : null
const content = (body.content || '').trim()
const message_type = body.message_type || 'text'
const conversation_id = body.conversation_id || `conv_${Date.now()}`
const metadata = body.metadata || null
if (!content) {
return NextResponse.json(
{ error: '"content" is required' },
{ status: 400 }
)
}
// Scan content for injection when it will be forwarded to an agent
if (body.forward && to) {
const injectionReport = scanForInjection(content, { context: 'prompt' })
if (!injectionReport.safe) {
const criticals = injectionReport.matches.filter(m => m.severity === 'critical')
if (criticals.length > 0) {
logger.warn({ to, rules: criticals.map(m => m.rule) }, 'Blocked chat message: injection detected')
return NextResponse.json(
{ error: 'Message blocked: potentially unsafe content detected', injection: criticals.map(m => ({ rule: m.rule, description: m.description })) },
{ status: 422 }
)
}
}
}
const stmt = db.prepare(`
INSERT INTO messages (conversation_id, from_agent, to_agent, content, message_type, metadata, workspace_id)
VALUES (?, ?, ?, ?, ?, ?, ?)
`)
const result = stmt.run(
conversation_id,
from,
to,
content,
message_type,
metadata ? JSON.stringify(metadata) : null,
workspaceId
)
const messageId = result.lastInsertRowid as number
let forwardInfo: ForwardInfo | null = null
// Log activity
db_helpers.logActivity(
'chat_message',
'message',
messageId,
from,
`Sent ${message_type} message${to ? ` to ${to}` : ' (broadcast)'}`,
{ conversation_id, to, message_type },
workspaceId
)
// Create notification for recipient if specified
if (to) {
db_helpers.createNotification(
to,
'chat_message',
`Message from ${from}`,
content.substring(0, 200) + (content.length > 200 ? '...' : ''),
'message',
messageId,
workspaceId
)
// Optionally forward to agent via gateway
if (body.forward) {
forwardInfo = { attempted: true, delivered: false }
const agent = db
.prepare('SELECT * FROM agents WHERE lower(name) = lower(?) AND workspace_id = ?')
.get(to, workspaceId) as any
const explicitSessionKey = typeof body.sessionKey === 'string' && body.sessionKey
? body.sessionKey
: 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 match = sessions.find(
(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 = coordinatorResolution.openclawAgentId
if (!sessionKey && !openclawAgentId) {
forwardInfo.reason = 'no_active_session'
// For coordinator messages, emit an immediate visible status reply
if (typeof conversation_id === 'string' && conversation_id.startsWith('coord:')) {
try {
createChatReply(
db,
workspaceId,
conversation_id,
COORDINATOR_AGENT,
from,
'I received your message, but my live coordinator session is offline right now. Start/restore the coordinator session and retry.',
'status',
{ status: 'offline', reason: 'no_active_session' }
)
} catch (e) {
logger.error({ err: e }, 'Failed to create offline status reply')
}
}
} else {
try {
const idempotencyKey = `mc-${messageId}-${Date.now()}`
if (sessionKey) {
const acceptedPayload = await callOpenClawGateway<any>(
'chat.send',
{
sessionKey,
message: content,
idempotencyKey,
deliver: false,
attachments: toGatewayAttachments(body.attachments),
},
12000,
)
const status = String(acceptedPayload?.status || '').toLowerCase()
forwardInfo.delivered = status === 'started' || status === 'ok' || status === 'in_flight'
forwardInfo.session = sessionKey
if (typeof acceptedPayload?.runId === 'string' && acceptedPayload.runId) {
forwardInfo.runId = acceptedPayload.runId
}
} else {
const invokeParams: any = {
message: `Message from ${from}: ${content}`,
idempotencyKey,
deliver: false,
}
invokeParams.agentId = openclawAgentId
const invokeResult = await runOpenClaw(
[
'gateway',
'call',
'agent',
'--timeout',
'10000',
'--params',
JSON.stringify(invokeParams),
'--json',
],
{ timeoutMs: 12000 }
)
const acceptedPayload = parseGatewayJson(invokeResult.stdout)
forwardInfo.delivered = true
forwardInfo.session = openclawAgentId || undefined
if (typeof acceptedPayload?.runId === 'string' && acceptedPayload.runId) {
forwardInfo.runId = acceptedPayload.runId
}
}
} catch (err) {
// OpenClaw may return accepted JSON on stdout but still emit a late stderr warning.
// Treat accepted runs as successful delivery.
const maybeStdout = String((err as any)?.stdout || '')
const acceptedPayload = parseGatewayJson(maybeStdout)
if (maybeStdout.includes('"status": "accepted"') || maybeStdout.includes('"status":"accepted"')) {
forwardInfo.delivered = true
forwardInfo.session = sessionKey || openclawAgentId || undefined
if (typeof acceptedPayload?.runId === 'string' && acceptedPayload.runId) {
forwardInfo.runId = acceptedPayload.runId
}
} else {
forwardInfo.reason = 'gateway_send_failed'
logger.error({ err }, 'Failed to forward message via gateway')
// For coordinator messages, emit visible status when send fails
if (typeof conversation_id === 'string' && conversation_id.startsWith('coord:')) {
try {
createChatReply(
db,
workspaceId,
conversation_id,
COORDINATOR_AGENT,
from,
'I received your message, but delivery to the live coordinator runtime failed. Please restart the coordinator/gateway session and retry.',
'status',
{ status: 'delivery_failed', reason: 'gateway_send_failed' }
)
} catch (e) {
logger.error({ err: e }, 'Failed to create gateway failure status reply')
}
}
}
}
// Coordinator mode should always show visible coordinator feedback in thread.
if (
typeof conversation_id === 'string' &&
conversation_id.startsWith('coord:') &&
forwardInfo.delivered
) {
try {
createChatReply(
db,
workspaceId,
conversation_id,
COORDINATOR_AGENT,
from,
'Received. I am coordinating downstream agents now.',
'status',
{ status: 'accepted', runId: forwardInfo.runId || null }
)
} catch (e) {
logger.error({ err: e }, 'Failed to create accepted status reply')
}
// Best effort: wait briefly and surface completion/error feedback.
if (forwardInfo.runId) {
try {
const waitResult = await runOpenClaw(
[
'gateway',
'call',
'agent.wait',
'--timeout',
'8000',
'--params',
JSON.stringify({ runId: forwardInfo.runId, timeoutMs: 6000 }),
'--json',
],
{ timeoutMs: 9000 }
)
const waitPayload = parseGatewayJson(waitResult.stdout)
const waitStatus = String(waitPayload?.status || '').toLowerCase()
const toolEvents = extractToolEvents(waitPayload)
if (toolEvents.length > 0) {
for (const evt of toolEvents) {
createChatReply(
db,
workspaceId,
conversation_id,
COORDINATOR_AGENT,
from,
evt.name,
'tool_call',
{
event: 'tool_call',
toolName: evt.name,
input: evt.input || null,
output: evt.output || null,
status: evt.status || null,
runId: forwardInfo.runId || null,
}
)
}
}
if (waitStatus === 'error') {
const reason =
typeof waitPayload?.error === 'string'
? waitPayload.error
: 'Unknown runtime error'
createChatReply(
db,
workspaceId,
conversation_id,
COORDINATOR_AGENT,
from,
`I received your message, but execution failed: ${reason}`,
'status',
{ status: 'error', runId: forwardInfo.runId }
)
} else if (waitStatus === 'timeout') {
createChatReply(
db,
workspaceId,
conversation_id,
COORDINATOR_AGENT,
from,
'I received your message and I am still processing it. I will post results as soon as execution completes.',
'status',
{ status: 'processing', runId: forwardInfo.runId }
)
} else {
const replyText = extractReplyText(waitPayload)
if (replyText) {
createChatReply(
db,
workspaceId,
conversation_id,
COORDINATOR_AGENT,
from,
replyText,
'text',
{ status: waitStatus || 'completed', runId: forwardInfo.runId }
)
} else {
createChatReply(
db,
workspaceId,
conversation_id,
COORDINATOR_AGENT,
from,
'Execution accepted and completed. No textual response payload was returned by the runtime.',
'status',
{ status: waitStatus || 'completed', runId: forwardInfo.runId }
)
}
}
} catch (waitErr) {
const maybeWaitStdout = String((waitErr as any)?.stdout || '')
const maybeWaitStderr = String((waitErr as any)?.stderr || '')
const waitPayload = parseGatewayJson(maybeWaitStdout)
const reason =
typeof waitPayload?.error === 'string'
? waitPayload.error
: (maybeWaitStderr || maybeWaitStdout || 'Unable to read completion status from coordinator runtime.').trim()
createChatReply(
db,
workspaceId,
conversation_id,
COORDINATOR_AGENT,
from,
`I received your message, but I could not retrieve completion output yet: ${reason}`,
'status',
{ status: 'unknown', runId: forwardInfo.runId }
)
}
}
}
}
}
}
const created = db.prepare('SELECT * FROM messages WHERE id = ? AND workspace_id = ?').get(messageId, workspaceId) as Message
const parsedMessage = {
...created,
metadata: {
...(safeParseMetadata(created.metadata) || {}),
forwardInfo: forwardInfo || undefined,
},
}
// Broadcast to SSE clients
eventBus.broadcast('chat.message', parsedMessage)
return NextResponse.json({ message: parsedMessage, forward: forwardInfo }, { status: 201 })
} catch (error) {
logger.error({ err: error }, 'POST /api/chat/messages error')
return NextResponse.json({ error: 'Failed to send message' }, { status: 500 })
}
}