From adaa4de0a8c84fbf44e4981bf46a925c3a4ee868 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Tue, 30 Jun 2026 14:30:53 +0800 Subject: [PATCH] feat(server-v2): port v1 /sessions/:sid/skills routes - add GET /sessions/{session_id}/skills and POST .../{name}:activate, mirroring the v1 wire contract and protocol schemas - gate both endpoints on activated (live) sessions via ISessionLifecycleService; when persisted but not live, return 40401 with a "you need to activate it first" hint - resolve ISkillCatalog for listing and the main agent's IAgentSkillService for activation; map skill.not_found to 40415 and skill.type_unsupported to 40912 - export IAgentSkillService and ISkillCatalog from agent-core-v2 --- packages/agent-core-v2/src/index.ts | 1 + .../src/routes/registerApiV1Routes.ts | 2 + packages/server-v2/src/routes/skills.ts | 265 ++++++++++++++++++ packages/server-v2/test/skills.test.ts | 191 +++++++++++++ 4 files changed, 459 insertions(+) create mode 100644 packages/server-v2/src/routes/skills.ts create mode 100644 packages/server-v2/test/skills.test.ts diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 2d5d8d708..0e0feeef5 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -22,6 +22,7 @@ export * from './modelCatalog/index'; export * from './modelRuntime/index'; import './skill/index'; +export { IAgentSkillService, ISkillCatalog } from './skill/index'; export * from './permission/index'; import './flag/index'; export * from './flag/index'; diff --git a/packages/server-v2/src/routes/registerApiV1Routes.ts b/packages/server-v2/src/routes/registerApiV1Routes.ts index b6e32db91..1673c7e6a 100644 --- a/packages/server-v2/src/routes/registerApiV1Routes.ts +++ b/packages/server-v2/src/routes/registerApiV1Routes.ts @@ -26,6 +26,7 @@ import { registerPromptsRoutes } from './prompts'; import { registerQuestionsRoutes } from './questions'; import { registerSessionsRoutes } from './sessions'; import { registerShutdownRoutes } from './shutdown'; +import { registerSkillsRoutes } from './skills'; import { registerTasksRoutes } from './tasks'; import { registerToolsRoutes } from './tools'; import { registerWorkspaceFsRoutes } from './workspaceFs'; @@ -78,6 +79,7 @@ export async function registerApiV1Routes( apiV1 as unknown as Parameters[0], core, ); + registerSkillsRoutes(apiV1 as unknown as Parameters[0], core); registerMessagesRoutes( apiV1 as unknown as Parameters[0], core, diff --git a/packages/server-v2/src/routes/skills.ts b/packages/server-v2/src/routes/skills.ts new file mode 100644 index 000000000..6d985e4f0 --- /dev/null +++ b/packages/server-v2/src/routes/skills.ts @@ -0,0 +1,265 @@ +/** + * `/sessions/{session_id}/skills*` REST routes — server-v2 port. + * + * Mirrors the v1 server's wire contract + * (`packages/server/src/routes/skills.ts`) path-for-path and schema-for-schema: + * + * GET /sessions/{session_id}/skills data: {skills: SkillDescriptor[]} + * POST /sessions/{session_id}/skills/{skill_name}:activate body: {args?} data: {activated: true, skill_name} + * + * **Activation gate**: by convention these endpoints are only valid for an + * *activated* session — one that is live in `ISessionLifecycleService`. When + * the session is not in the live map we still answer `40401 session.not_found` + * (the only session error code on the v1 wire contract), but we enrich the + * message: + * - persisted in `ISessionIndex` but not live → `"... is not activated, you need to activate it first"`; + * - not in the index at all → `"... does not exist"`. + * + * **Scope split**: v1 resolves a single `ISkillService` for both verbs. v2 + * splits the domain, so the route borrows two scoped services: + * - list → `ISkillCatalog` (Session scope) — `catalog.listSkills()`. + * - activate → `IAgentSkillService` (Agent scope, on the `main` agent) — + * renders the skill prompt and starts a turn with a + * `skill_activation` origin. The returned `Turn` handle is + * discarded; clients follow progress via the `skill.activated` + * + `turn.*` events emitted by the service on the WS stream. + * + * **Model projection**: `SkillDefinition` (v2) → protocol `SkillDescriptor`, + * byte-for-byte with v1's `toProtocolSkill` + * (`packages/agent-core/src/services/skill/skill.ts`): only + * `name`/`description`/`path`/`source` plus optional `type` and + * `disable_model_invocation` are emitted; `isSubSkill` is intentionally + * dropped. + * + * **Error mapping**: + * - not live / unknown session → envelope `code: 40401 session.not_found` (see gate above). + * - `skill.not_found` / `skill.name_empty` → envelope `code: 40415 skill.not_found`. + * - `skill.type_unsupported` → envelope `code: 40912 skill.not_activatable`. + * - malformed `{tail}` (bad action, bare) → envelope `code: 40001 validation.failed`. + * - other errors → 50001 via the global `installErrorHandler`. + * + * **Action suffix**: the `:activate` POST endpoint uses the shared + * `parseActionSuffix` helper (no bare form — `:activate` is the only action). + * + * **Anti-corruption**: route resolves `ISkillCatalog` / `IAgentSkillService` + * via the accessor; no SDK imports. + */ + +import { + ErrorCodes, + IAgentSkillService, + ISessionIndex, + ISessionLifecycleService, + ISkillCatalog, + isKimiError, + type IScopeHandle, + type Scope, +} from '@moonshot-ai/agent-core-v2'; +import { + ErrorCode, + activateSkillRequestSchema, + activateSkillResultSchema, + listSkillsResponseSchema, + type SkillDescriptor, +} from '@moonshot-ai/protocol'; +import { z } from 'zod'; + +import { errEnvelope, okEnvelope } from '../envelope'; +import { defineRoute } from '../middleware/defineRoute'; +import { ensureMainAgent } from '../transport/mainAgent'; +import { parseActionSuffix } from './action-suffix'; + +interface SkillsRouteHost { + get( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; + post( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; body: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; +} + +const sessionIdParamSchema = z.object({ + session_id: z.string().min(1), +}); + +const skillTailParamsSchema = z.object({ + session_id: z.string().min(1), + tail: z.string().min(1), +}); + +type ResolvedSession = + | { readonly handle: IScopeHandle } + | { readonly envelope: ReturnType }; + +/** + * Resolve the session only when it is activated (live in the lifecycle map). + * Otherwise build a `40401` envelope whose message distinguishes "not + * activated" (persisted but not live) from "does not exist" (not persisted). + */ +async function resolveActivatedSession( + core: Scope, + sessionId: string, + requestId: string, +): Promise { + const handle = core.accessor.get(ISessionLifecycleService).get(sessionId); + if (handle !== undefined) return { handle }; + + const summary = await core.accessor.get(ISessionIndex).get(sessionId); + const msg = + summary === undefined + ? `session ${sessionId} does not exist` + : `session ${sessionId} is not activated, you need to activate it first`; + return { envelope: errEnvelope(ErrorCode.SESSION_NOT_FOUND, msg, requestId) }; +} + +export function registerSkillsRoutes(app: SkillsRouteHost, core: Scope): void { + // GET /sessions/{session_id}/skills ------------------------------------ + const listSkillsRoute = defineRoute( + { + method: 'GET', + path: '/sessions/{session_id}/skills', + params: sessionIdParamSchema, + success: { data: listSkillsResponseSchema }, + errors: { + [ErrorCode.SESSION_NOT_FOUND]: {}, + }, + description: 'List the skills available to a session', + tags: ['skills'], + operationId: 'listSkills', + }, + async (req, reply) => { + const { session_id } = req.params; + const resolved = await resolveActivatedSession(core, session_id, req.id); + if ('envelope' in resolved) { + reply.send(resolved.envelope); + return; + } + const catalog = resolved.handle.accessor.get(ISkillCatalog); + await catalog.ready; + const skills = catalog.catalog.listSkills().map(toProtocolSkill); + reply.send(okEnvelope({ skills }, req.id)); + }, + ); + app.get( + listSkillsRoute.path, + listSkillsRoute.options, + listSkillsRoute.handler as Parameters[2], + ); + + // POST /sessions/{session_id}/skills/{skill_name}:activate -------------- + const activateSkillRoute = defineRoute( + { + method: 'POST', + path: '/sessions/{session_id}/skills/{tail}', + body: activateSkillRequestSchema, + params: skillTailParamsSchema, + success: { data: activateSkillResultSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: {}, + [ErrorCode.SESSION_NOT_FOUND]: {}, + [ErrorCode.SKILL_NOT_FOUND]: {}, + [ErrorCode.SKILL_NOT_ACTIVATABLE]: {}, + }, + description: 'Activate a skill in a session (REST analogue of the / slash command)', + tags: ['skills'], + operationId: 'activateSkill', + }, + async (req, reply) => { + const { session_id, tail } = req.params; + const parsed = parseActionSuffix({ + tail, + allowedActions: ['activate'] as const, + resourceLabel: 'skill_name', + }); + if (parsed.kind === 'invalid') { + reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, parsed.reason, req.id)); + return; + } + if (parsed.kind === 'bare') { + // No bare form for /skills/{name} — only :activate. + reply.send( + errEnvelope(ErrorCode.VALIDATION_FAILED, `unsupported action: ${tail}`, req.id), + ); + return; + } + + const resolved = await resolveActivatedSession(core, session_id, req.id); + if ('envelope' in resolved) { + reply.send(resolved.envelope); + return; + } + + try { + const agent = await ensureMainAgent(resolved.handle); + await agent.accessor + .get(IAgentSkillService) + .activate({ name: parsed.id, args: req.body.args }); + reply.send(okEnvelope({ activated: true, skill_name: parsed.id }, req.id)); + } catch (err) { + sendMappedError(reply, req.id, err); + } + }, + ); + app.post( + activateSkillRoute.path, + activateSkillRoute.options, + activateSkillRoute.handler as Parameters[2], + ); +} + +// --------------------------------------------------------------------------- +// Projection — v2 `SkillDefinition` → protocol `SkillDescriptor` (see header). +// --------------------------------------------------------------------------- + +type SkillElement = ReturnType[number]; + +function toProtocolSkill(skill: SkillElement): SkillDescriptor { + const base: SkillDescriptor = { + name: skill.name, + description: skill.description, + path: skill.path, + source: skill.source, + }; + const type = skill.metadata.type; + const disableModelInvocation = skill.metadata.disableModelInvocation; + return { + ...base, + ...(type !== undefined ? { type } : {}), + ...(disableModelInvocation !== undefined + ? { disable_model_invocation: disableModelInvocation } + : {}), + }; +} + +// --------------------------------------------------------------------------- +// Error mapping (see header). +// --------------------------------------------------------------------------- + +function sendMappedError( + reply: { send(payload: unknown): unknown }, + requestId: string, + err: unknown, +): void { + if (isKimiError(err)) { + switch (err.code) { + case ErrorCodes.SKILL_NOT_FOUND: + case ErrorCodes.SKILL_NAME_EMPTY: + reply.send(errEnvelope(ErrorCode.SKILL_NOT_FOUND, err.message, requestId)); + return; + case ErrorCodes.SKILL_TYPE_UNSUPPORTED: + reply.send(errEnvelope(ErrorCode.SKILL_NOT_ACTIVATABLE, err.message, requestId)); + return; + } + } + throw err; +} diff --git a/packages/server-v2/test/skills.test.ts b/packages/server-v2/test/skills.test.ts new file mode 100644 index 000000000..9b90f3e07 --- /dev/null +++ b/packages/server-v2/test/skills.test.ts @@ -0,0 +1,191 @@ +/** + * `/api/v1` skills routes — server-v2 port of `packages/server/test/skills.e2e.test.ts`. + * + * Covers the wire contract of the two endpoints: + * - GET /api/v1/sessions/{sid}/skills → envelope shape + skills[] + * - GET on an unknown session → 40401 "does not exist" + * - GET on a persisted-but-not-activated session → 40401 "not activated ..." + * - POST /api/v1/sessions/{sid}/skills/{name}:activate → {activated:true, skill_name} + * - POST :activate an unknown skill → 40415 + * - POST bare `{name}` / bogus action → 40001 + * + * Skills are resolved from the per-session `ISkillCatalog` (list) and the main + * agent's `IAgentSkillService` (activate). A session created through + * `POST /sessions` is already activated (live), so listing/activation work + * immediately; the "not activated" branch is exercised by archiving the session + * (it stays in the index but leaves the live map). + */ + +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + IAgentLifecycleService, + ISessionLifecycleService, +} from '@moonshot-ai/agent-core-v2'; +import { + activateSkillResultSchema, + listSkillsResponseSchema, +} from '@moonshot-ai/protocol'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { type RunningServer, startServer } from '../src/start'; + +interface Envelope { + code: number; + msg: string; + data: T; + request_id: string; +} + +interface SkillWire { + name: string; + description: string; + path: string; + source: string; + type?: string; + disable_model_invocation?: boolean; +} + +describe('server-v2 /api/v1 skills', () => { + let server: RunningServer | undefined; + let home: string | undefined; + let base: string; + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-skills-')); + server = await startServer({ host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); + base = `http://127.0.0.1:${server.port}`; + }); + + afterEach(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true }); + home = undefined; + } + }); + + async function getJson(path: string): Promise<{ status: number; body: Envelope }> { + const res = await fetch(`${base}${path}`); + return { status: res.status, body: (await res.json()) as Envelope }; + } + + async function postJson( + path: string, + body?: unknown, + ): Promise<{ status: number; body: Envelope }> { + const res = await fetch(`${base}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body ?? {}), + }); + return { status: res.status, body: (await res.json()) as Envelope }; + } + + async function createSession(): Promise { + const { body } = await postJson<{ id: string }>('/api/v1/sessions', { + metadata: { cwd: home as string }, + }); + expect(body.code).toBe(0); + return body.data.id; + } + + // The main agent scope is not created automatically on session creation + // (server-v2 gap G10); create it here so skill activation can start a turn. + async function createMainAgent(sessionId: string): Promise { + const session = server!.core.accessor.get(ISessionLifecycleService).get(sessionId); + if (session === undefined) throw new Error(`session ${sessionId} not found`); + const agents = session.accessor.get(IAgentLifecycleService); + if (agents.getHandle('main') === undefined) await agents.createMain(); + } + + describe('GET /api/v1/sessions/{sid}/skills', () => { + it('returns 40401 for an unknown session', async () => { + const { body } = await getJson('/api/v1/sessions/nope/skills'); + expect(body.code).toBe(40401); + expect(body.msg).toMatch(/does not exist/); + }); + + it('returns 40401 with an activation hint for a persisted but not activated session', async () => { + const id = await createSession(); + // Archiving removes the session from the live map but keeps it in the index. + const archived = await postJson<{ archived: boolean }>(`/api/v1/sessions/${id}:archive`); + expect(archived.body.code).toBe(0); + + const { body } = await getJson(`/api/v1/sessions/${id}/skills`); + expect(body.code).toBe(40401); + expect(body.msg).toMatch(/not activated/); + expect(body.msg).toMatch(/activate it first/); + }); + + it('lists builtin skills projected to the wire shape', async () => { + const id = await createSession(); + const { body } = await getJson<{ skills: SkillWire[] }>( + `/api/v1/sessions/${id}/skills`, + ); + expect(body.code).toBe(0); + const skills = listSkillsResponseSchema.parse(body.data).skills; + + const updateConfig = skills.find((s) => s.name === 'update-config'); + expect(updateConfig).toBeDefined(); + expect(updateConfig).toMatchObject({ source: 'builtin' }); + // v1 parity: `isSubSkill` is never emitted on the wire. + expect(updateConfig).not.toHaveProperty('is_sub_skill'); + expect(updateConfig).not.toHaveProperty('isSubSkill'); + }); + }); + + describe('POST /api/v1/sessions/{sid}/skills/{name}:activate', () => { + it('activates a builtin skill and returns the wire envelope', async () => { + const id = await createSession(); + await createMainAgent(id); + + const { body } = await postJson<{ activated: boolean; skill_name: string }>( + `/api/v1/sessions/${id}/skills/update-config:activate`, + { args: '--help' }, + ); + expect(body.code).toBe(0); + expect(activateSkillResultSchema.parse(body.data)).toEqual({ + activated: true, + skill_name: 'update-config', + }); + }); + + it('returns 40415 for an unknown skill', async () => { + const id = await createSession(); + await createMainAgent(id); + + const { body } = await postJson( + `/api/v1/sessions/${id}/skills/does-not-exist:activate`, + ); + expect(body.code).toBe(40415); + }); + + it('returns 40401 for an unknown session', async () => { + const { body } = await postJson('/api/v1/sessions/nope/skills/update-config:activate'); + expect(body.code).toBe(40401); + expect(body.msg).toMatch(/does not exist/); + }); + + it('rejects a bare {name} (no action) with 40001', async () => { + const id = await createSession(); + const { body } = await postJson(`/api/v1/sessions/${id}/skills/update-config`); + expect(body.code).toBe(40001); + expect(body.msg).toMatch(/unsupported action/); + }); + + it('rejects an unsupported action with 40001', async () => { + const id = await createSession(); + const { body } = await postJson( + `/api/v1/sessions/${id}/skills/update-config:bogus`, + ); + expect(body.code).toBe(40001); + expect(body.msg).toMatch(/unsupported action/); + }); + }); +});