import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import type { Sql } from '../db.js'; import type { Chat } from '../types/api.js'; import { getSkillBody, listSkills } from '../services/skills.js'; import { buildSkillInvokeSyntheticFrames, DEFAULT_SKILL_USER_MESSAGE, runSkillInvokeTransaction, } from '../services/skill-invoke.js'; // Batch 9.6 slash-invoke handlers. Mirrors the MessageHandlers shape in // routes/messages.ts so index.ts can pass thin adapters around broker + // inference runner without skills.ts importing them directly. export interface SkillInvokeHandlers { enqueueInference: ( sessionId: string, chatId: string, assistantMessageId: string, user: string, ) => void; publishUserMessage: ( sessionId: string, chatId: string, userMessageId: string, content: string, ) => void; publishSessionFrame: ( sessionId: string, frame: Record & { type: string }, ) => void; } const SkillInvokeBody = z.object({ skill_name: z.string().min(1), // Optional — server fills in a default if absent or whitespace-only so the // model always has something to act on (matches the spec's "Apply this // skill." filler). user_message: z.string().max(64_000).nullable().optional(), }); export function registerSkillsRoutes( app: FastifyInstance, sql: Sql, handlers: SkillInvokeHandlers, ): void { // Debug/admin surface — the model interacts with skills via the three // skill_* tools, not through this endpoint. app.get('/api/skills', async () => { return { skills: await listSkills() }; }); // POST /api/chats/:id/skill_invoke — slash-command entry point. Loads the // skill body server-side (clients never get to forge file content), // persists 4 messages in one transaction (synthetic assistant tool_use, // synthetic tool result, real user message, streaming assistant), and // enqueues inference against the updated history. app.post<{ Params: { id: string } }>( '/api/chats/:id/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 { skill_name } = parsed.data; const userText = parsed.data.user_message?.trim() ? parsed.data.user_message : DEFAULT_SKILL_USER_MESSAGE; const chatRows = await sql` SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open' `; if (chatRows.length === 0) { reply.code(404); return { error: 'chat not found' }; } const chat = chatRows[0]!; const sessionId = chat.session_id; const body = await getSkillBody(skill_name); if (body === null) { reply.code(404); return { error: 'unknown_skill', message: `unknown skill: ${skill_name}` }; } const { result, toolCall } = await runSkillInvokeTransaction(sql, { sessionId, chatId: chat.id, skillName: skill_name, skillBody: body, userText, }); // Synthetic frames so useSessionStream's reducer reflects the new // history without a refetch. Frame shapes match the streaming-inference // protocol (see services/inference.ts InferenceFrame). for (const frame of buildSkillInvokeSyntheticFrames(chat.id, result, toolCall, body)) { handlers.publishSessionFrame(sessionId, frame); } handlers.publishUserMessage(sessionId, chat.id, result.user_message_id, userText); handlers.enqueueInference(sessionId, chat.id, result.assistant_message_id, 'default'); reply.code(202); return result; }, ); }