mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-21 06:35:50 +00:00
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:
parent
f3ebc1e2a7
commit
adaa4de0a8
4 changed files with 459 additions and 0 deletions
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
265
packages/server-v2/src/routes/skills.ts
Normal file
265
packages/server-v2/src/routes/skills.ts
Normal 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;
|
||||
}
|
||||
191
packages/server-v2/test/skills.test.ts
Normal file
191
packages/server-v2/test/skills.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue