import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import type { Sql } from '../db.js'; import type { Broker } from '@boocode/server/broker'; import type { WsFrame } from '@boocode/server/ws-frames'; import { getSkillBody } from '@boocode/server/skills'; import { buildSkillInvokeSyntheticFrames, buildSkillInvokeUserFrames, DEFAULT_SKILL_USER_MESSAGE, runSkillInvokeTransaction, } from '@boocode/server/skill-invoke'; import { resolveChatId } from './chat-resolve.js'; const SkillInvokeBody = z.object({ pane_id: z.string().min(1).max(200), skill_name: z.string().min(1), user_message: z.string().max(64_000).nullable().optional(), // v2.5.9: when set to an external provider, the skill runs UNDER that agent — // its body is injected into a dispatched task instead of native inference. provider: z.string().max(100).optional(), model: z.string().max(200).optional(), mode_id: z.string().max(200).optional(), thinking_option_id: z.string().max(200).optional(), }); interface InferenceApi { enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void; hasActive: (chatId: string) => boolean; } export function registerSkillRoutes( app: FastifyInstance, sql: Sql, broker: Broker, inference: InferenceApi, ): void { app.post<{ Params: { sessionId: string } }>( '/api/sessions/:sessionId/skill_invoke', async (req, reply) => { const parsed = SkillInvokeBody.safeParse(req.body); if (!parsed.success) { reply.code(400); return { error: 'invalid body', details: parsed.error.flatten() }; } const sessionId = req.params.sessionId; const { pane_id, skill_name, provider, model, mode_id, thinking_option_id } = parsed.data; const sessionRows = await sql<{ id: string; project_id: string }[]>` SELECT id, project_id FROM sessions WHERE id = ${sessionId} `; if (sessionRows.length === 0) { reply.code(404); return { error: 'session not found' }; } const chatId = await resolveChatId(sql, sessionId, pane_id); if (!chatId) { reply.code(404); return { error: 'pane not found' }; } if (inference.hasActive(chatId)) { reply.code(409); return { error: 'inference already running on this chat' }; } const userText = parsed.data.user_message?.trim() ? parsed.data.user_message : DEFAULT_SKILL_USER_MESSAGE; const body = await getSkillBody(skill_name); if (body === null) { reply.code(404); return { error: 'unknown_skill', message: `unknown skill: ${skill_name}` }; } // v2.5.9: external agent → run the skill UNDER that agent. The skill body // stays server-side (like the native path's tool message) and is injected // into a dispatched task; the agent receives the skill instructions + the // user's text. Mirrors the messages-route external-provider dispatch. if (provider && provider !== 'boocode') { const [userMsg] = await sql<{ id: string }[]>` INSERT INTO messages (session_id, chat_id, role, content, status, created_at) VALUES (${sessionId}, ${chatId}, 'user', ${userText}, 'complete', clock_timestamp()) RETURNING id `; broker.publishFrame(sessionId, { type: 'message_started', message_id: userMsg!.id, chat_id: chatId, role: 'user' } as WsFrame); broker.publishFrame(sessionId, { type: 'delta', message_id: userMsg!.id, chat_id: chatId, content: userText } as WsFrame); broker.publishFrame(sessionId, { type: 'message_complete', message_id: userMsg!.id, chat_id: chatId } as WsFrame); const taskInput = `${body}\n\n---\n\n${userText}`; const [task] = await sql<{ id: string; state: string }[]>` INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id, session_id, chat_id) VALUES (${sessionRows[0]!.project_id}, ${taskInput}, ${provider}, ${model ?? null}, ${mode_id ?? null}, ${thinking_option_id ?? null}, ${sessionId}, ${chatId}) RETURNING id, state `; await sql`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chatId}`; reply.code(202); return { user_message_id: userMsg!.id, task_id: task!.id, dispatched: true }; } const { result, toolCall } = await runSkillInvokeTransaction(sql, { sessionId, chatId, skillName: skill_name, skillBody: body, userText, }); for (const frame of buildSkillInvokeSyntheticFrames(chatId, result, toolCall, body)) { broker.publishFrame(sessionId, frame as WsFrame); } for (const frame of buildSkillInvokeUserFrames(chatId, result.user_message_id, userText)) { broker.publishFrame(sessionId, frame as WsFrame); } inference.enqueue(sessionId, chatId, result.assistant_message_id, 'default'); reply.code(202); return result; }, ); }