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
This commit is contained in:
haozhe.yang 2026-06-30 14:30:53 +08:00
parent f3ebc1e2a7
commit adaa4de0a8
4 changed files with 459 additions and 0 deletions

View file

@ -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';

View file

@ -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<typeof registerSessionsRoutes>[0],
core,
);
registerSkillsRoutes(apiV1 as unknown as Parameters<typeof registerSkillsRoutes>[0], core);
registerMessagesRoutes(
apiV1 as unknown as Parameters<typeof registerMessagesRoutes>[0],
core,

View file

@ -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<string, unknown> },
handler: (
req: { id: string; params: unknown },
reply: { send(payload: unknown): unknown },
) => Promise<void> | void,
): unknown;
post(
path: string,
options: { preHandler: unknown[]; schema?: Record<string, unknown> },
handler: (
req: { id: string; body: unknown; params: unknown },
reply: { send(payload: unknown): unknown },
) => Promise<void> | 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<typeof errEnvelope> };
/**
* 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<ResolvedSession> {
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<SkillsRouteHost['get']>[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 /<skill> 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<SkillsRouteHost['post']>[2],
);
}
// ---------------------------------------------------------------------------
// Projection — v2 `SkillDefinition` → protocol `SkillDescriptor` (see header).
// ---------------------------------------------------------------------------
type SkillElement = ReturnType<ISkillCatalog['catalog']['listSkills']>[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;
}

View file

@ -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<T> {
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<T>(path: string): Promise<{ status: number; body: Envelope<T> }> {
const res = await fetch(`${base}${path}`);
return { status: res.status, body: (await res.json()) as Envelope<T> };
}
async function postJson<T>(
path: string,
body?: unknown,
): Promise<{ status: number; body: Envelope<T> }> {
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<T> };
}
async function createSession(): Promise<string> {
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<void> {
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<null>('/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<null>(`/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<null>(
`/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<null>('/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<null>(`/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<null>(
`/api/v1/sessions/${id}/skills/update-config:bogus`,
);
expect(body.code).toBe(40001);
expect(body.msg).toMatch(/unsupported action/);
});
});
});