feat(kap-server): accept attachments on skill activation (#2693)

* feat(kap-server): accept attachments on skill activation

The :activate endpoint only took {args?}, so REST clients (web/desktop
composers) could not attach uploads to a /skill invocation — attachments
were silently dropped at the edge.

- activateSkillRequestSchema gains an optional attachments field carrying
  the image/video/file subset of the prompt content wire shape.
- The skills route resolves them through the same edge pipeline as prompt
  submissions (validate file refs → materialize/compress → convert),
  extracted from routes/prompts.ts into lib/promptMedia.ts.
- AgentSkillService.activate appends the resolved parts after the rendered
  skill prompt in the activation's user message; SkillActivationInput
  gains an optional content field. The native RPC/TUI path is unchanged.
- Attachment failures map to 40407 file.not_found / 40001
  validation.failed, mirroring the prompts route.

* fix(kap-server): drop the unused parseKimiFileUrl import in promptMedia

* refactor: address review — header-only comments in the skill domain, provider id on protocol URL sources

- agent-core-v2 keeps comments solely in the top-of-file block (scoped
  guide): SkillActivationInput.content documented in the skill.ts header,
  the activate() note folded into the skillService.ts header.
- packages/protocol's image/video URL source gains the optional
  provider-issued id, matching the kap-server wire schema so parsing the
  public contract no longer strips it.

* fix(kap-server): validate the skill before materializing activation attachments

An unknown or non-user-activatable skill name with attachments ran the
media pipeline first, streaming bytes into the session/cache dirs and
compressing images for a request that activate() would reject with
40415/40912. The route now checks the session catalog up front (the
service still re-validates) so invalid activations leave no disk or CPU
side effects.
This commit is contained in:
liruifengv 2026-08-06 19:09:32 +08:00 committed by GitHub
parent 4d39f4fa6f
commit cfd14a1fe2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 671 additions and 400 deletions

View file

@ -1,10 +1,22 @@
/**
* `skill` domain user-slash skill activation contract.
*
* `SkillActivationInput` carries the slash name and raw args, plus optional
* edge-resolved attachment parts (`content`) that the activation appends after
* the rendered skill prompt in its user message. `IAgentSkillService` starts
* the activation turn (`activate`) and records model-tool activations without
* a turn (`recordModelToolActivation`). Bound at Agent scope.
*/
import { createDecorator } from "#/_base/di/instantiation";
import type { SkillActivationOrigin } from '#/agent/contextMemory/types';
import type { Turn } from '#/agent/loop/loop';
import type { ContentPart } from '#/kosong/contract/message';
export interface SkillActivationInput {
readonly name: string;
readonly args?: string;
readonly content?: readonly ContentPart[];
}
export interface IAgentSkillService {

View file

@ -5,10 +5,11 @@
* records the activation as a `skill.activate` fact through `wire.dispatch`
* (a stateless, identity-apply Op), derives the `skill.activated` event
* through the Op's `toEvent`, drives user-slash activations into a new turn via
* `prompt`, and reports `skill_invoked` / `flow_invoked` through `telemetry`.
* `wire.replay` reapplies the fact as a no-op, so neither the event nor
* telemetry fires on resume (matching the former `restoring` guard). Bound at
* Agent scope.
* `prompt` (attachment parts from the caller ride the same user message after
* the rendered prompt), and reports `skill_invoked` / `flow_invoked` through
* `telemetry`. `wire.replay` reapplies the fact as a no-op, so neither the
* event nor telemetry fires on resume (matching the former `restoring` guard).
* Bound at Agent scope.
*/
import { randomUUID } from 'node:crypto';
@ -70,6 +71,7 @@ export class AgentSkillService extends Service implements IAgentSkillService {
skillDir: skill.dir,
}),
},
...(input.content ?? []),
];
const turn = await this.recordActivation(

View file

@ -0,0 +1,430 @@
/**
* Prompt media/attachment pipeline shared by the prompt-submission and
* skill-activation edges.
*
* Three stages, in the order both routes apply them:
* 1. `assertPromptFileRefs` fail fast on stale or mis-kinded `file_id`
* references before anything session-scoped is resolved or mutated.
* 2. `resolvePromptMediaFiles` materialize uploads into session-local
* copies: arbitrary files become path-referenced attachments (a text
* notice the model opens with the Read tool), images are format-gated
* and compressed, videos become internal `kimi-file://` references.
* 3. `contentToCoreParts` project the resolved wire content onto engine
* `ContentPart`s.
*
* Extracted from `routes/prompts.ts` so `routes/skills.ts` can run the exact
* same pipeline for skill-activation attachments.
*/
import { createHash } from 'node:crypto';
import { createWriteStream } from 'node:fs';
import { mkdir, stat, writeFile } from 'node:fs/promises';
import { extname, join } from 'node:path';
import { pipeline } from 'node:stream/promises';
import {
buildKimiFileUrl,
buildImageCompressionCaption,
buildUnsupportedImageNotice,
compressBase64ForModel,
compressImageForModel,
decodeBase64Prefix,
Error2,
isModelAcceptedImageMime,
normalizeImageMime,
persistOriginalImage,
resolveEffectiveImageMime,
unsupportedImageMimeFromUrl,
type ContentPart,
type GetResult,
type IFileService,
type ImageCompressionTelemetry,
type ITelemetryService,
} from '@moonshot-ai/agent-core-v2';
import type { PromptSubmission } from '../protocol/rest-prompt';
/**
* The content list these helpers walk. Routes pass their own wire content:
* the full prompt submission's `content`, or a skill activation's
* `attachments` (same `MessageContent` parts, minus the text-ish kinds).
*/
type WireContent = PromptSubmission['content'];
const VIDEO_EXT_BY_MIME: Record<string, string> = {
'video/mp4': '.mp4',
'video/quicktime': '.mov',
'video/webm': '.webm',
'video/x-msvideo': '.avi',
'video/x-matroska': '.mkv',
'video/mpeg': '.mpeg',
};
/**
* Fail fast on stale or mis-kinded file references before anything
* session-scoped happens: a bad `file_id` (unknown, or a real file used with
* the wrong media kind, e.g. a PDF submitted as a video) must reject the
* request without creating the prompt agent and without touching the
* session's model/thinking/permission.
*/
export async function assertPromptFileRefs(content: WireContent, store: IFileService): Promise<void> {
for (const part of content) {
if (part.type === 'file') {
await store.get(part.file_id);
} else if ((part.type === 'image' || part.type === 'video') && part.source.kind === 'file') {
const file = await store.get(part.source.file_id);
assertMediaFile(file, part.type);
}
}
}
export function contentToCoreParts(content: WireContent): ContentPart[] {
const parts: ContentPart[] = [];
for (const part of content) {
if (part.type === 'text') parts.push({ type: 'text', text: part.text });
else if (part.type === 'image' && part.source.kind === 'url') parts.push({ type: 'image_url', imageUrl: { url: part.source.url, id: part.source.id } });
else if (part.type === 'image' && part.source.kind === 'base64') parts.push({ type: 'image_url', imageUrl: { url: `data:${part.source.media_type};base64,${part.source.data}` } });
else if (part.type === 'video' && part.source.kind === 'url') parts.push({ type: 'video_url', videoUrl: { url: part.source.url, id: part.source.id } });
else if (part.type === 'video' && part.source.kind === 'base64') parts.push({ type: 'video_url', videoUrl: { url: `data:${part.source.media_type};base64,${part.source.data}` } });
}
return parts;
}
export interface ResolvePromptMediaOptions {
/**
* Lazily resolve the session's media-originals dir for persisting the
* pre-compression bytes of inline base64 images. Only invoked when an image
* was actually compressed; a failure or undefined result falls back to the
* shared temp-dir cache.
*/
readonly resolveOriginalsDir?: () => Promise<string | undefined>;
/**
* Lazily resolve the session's attachments dir for materializing arbitrary
* file uploads (and image bytes the provider rejects) into a path the model
* can open with the Read tool. A failure or undefined result falls back to
* the shared cache dir.
*/
readonly resolveAttachmentsDir?: () => Promise<string | undefined>;
/** Report an `image_compress` event per compressed prompt image. */
readonly telemetry?: ITelemetryService;
}
/**
* Resolve a wire content list's media/file references into their final wire
* form: uploaded files materialize to a session-local path notice, images are
* format-gated and compressed, videos materialize to a `kimi-file://`
* reference. Returns the input array unchanged when nothing needed resolving.
*/
export async function resolvePromptMediaFiles(
input: WireContent,
store: IFileService,
cacheDir: string,
options: ResolvePromptMediaOptions = {},
): Promise<WireContent> {
let changed = false;
let originalsDir: string | undefined;
let originalsDirResolved = false;
const resolveOriginalsDir = async (): Promise<string | undefined> => {
if (!originalsDirResolved) {
originalsDirResolved = true;
originalsDir = await options.resolveOriginalsDir?.().catch(() => undefined);
}
return originalsDir;
};
let attachmentsDir: string | undefined;
let attachmentsDirResolved = false;
const resolveAttachmentsDir = async (): Promise<string> => {
if (!attachmentsDirResolved) {
attachmentsDirResolved = true;
attachmentsDir = await options.resolveAttachmentsDir?.().catch(() => undefined);
}
return attachmentsDir ?? cacheDir;
};
const telemetryFor = (source: string): ImageCompressionTelemetry | undefined =>
options.telemetry === undefined ? undefined : { client: options.telemetry, source };
const content: WireContent = [];
for (const part of input) {
// Inline base64 image: compress the payload in place. This mirrors the v1
// server path for REST clients that submit an image without uploading it.
if (part.type === 'image' && part.source.kind === 'base64') {
// Formats the provider cannot accept must never enter the session
// history — one unsupported image_url makes every later request fail.
// The bytes are authoritative: an image labeled image/png that is
// actually AVIF is gated on the sniffed format, not the label. The
// bytes are still the user's content, though: persist them as a
// path-referenced attachment so the model can read and convert them
// itself (best effort — the plain notice stands in when persisting
// fails). Inline base64 has no original name, so the file is addressed
// by content hash with a name derived from the sniffed format.
const effectiveMime = resolveEffectiveImageMime(
part.source.media_type,
decodeBase64Prefix(part.source.data),
);
if (!isModelAcceptedImageMime(effectiveMime)) {
const bytes = Buffer.from(part.source.data, 'base64');
const name = `image.${imageExtensionForMime(effectiveMime)}`;
const persisted = await persistAttachmentBytes(
bytes,
`${createHash('sha256').update(bytes).digest('hex').slice(0, 32)}-${name}`,
await resolveAttachmentsDir(),
);
content.push({
type: 'text',
text: persisted === null
? buildUnsupportedImageNotice(effectiveMime)
: buildAttachedFileNotice(name, effectiveMime, bytes.length, persisted),
});
changed = true;
continue;
}
const canonicalMime = normalizeImageMime(effectiveMime);
const compressed = await compressBase64ForModel(part.source.data, canonicalMime, {
telemetry: telemetryFor('prompt_inline'),
});
if (compressed.changed) {
const dir = await resolveOriginalsDir();
const originalPath = await persistOriginalImage(
Buffer.from(part.source.data, 'base64'),
part.source.media_type,
{ dir },
);
content.push({
type: 'text',
text: buildImageCompressionCaption({
original: {
width: compressed.originalWidth,
height: compressed.originalHeight,
byteLength: compressed.originalByteLength,
mimeType: part.source.media_type,
},
final: {
width: compressed.width,
height: compressed.height,
byteLength: compressed.finalByteLength,
mimeType: compressed.mimeType,
},
originalPath,
}),
});
content.push({
type: 'image',
source: { kind: 'base64', media_type: compressed.mimeType, data: compressed.base64 },
});
changed = true;
} else {
content.push(part);
}
continue;
}
// Remote image URL: no bytes to sniff, so reject when its path extension
// names a format providers reject (e.g. a link ending in `.avif`) — the
// notice keeps the URL so the model can still fetch and convert the
// image. Extensionless / unknown URLs pass through to the provider and
// the 400 recovery. Image+URL parts that pass are re-emitted unchanged.
if (part.type === 'image' && part.source.kind === 'url') {
const extMime = unsupportedImageMimeFromUrl(part.source.url);
if (extMime !== null) {
content.push({ type: 'text', text: buildUnsupportedImageNotice(extMime, part.source.url) });
changed = true;
continue;
}
content.push(part);
continue;
}
// Arbitrary file attachment: materialize the uploaded bytes next to the
// session and replace the part with a path reference — the model opens it
// with the Read tool instead of receiving it as a media part.
if (part.type === 'file') {
const file = await store.get(part.file_id);
const attachedPath = await materializeAttachmentToDir(file, await resolveAttachmentsDir());
content.push({
type: 'text',
text: buildAttachedFileNotice(file.meta.name, file.meta.media_type, file.meta.size, attachedPath),
});
changed = true;
continue;
}
if ((part.type !== 'image' && part.type !== 'video') || part.source.kind !== 'file') {
content.push(part);
continue;
}
const file = await store.get(part.source.file_id);
assertMediaFile(file, part.type);
if (part.type === 'image') {
const data = await readFileOrStream(file);
let mediaType = file.meta.media_type;
let bytes: Uint8Array = data;
// Same format gate as the inline path above, and again the bytes are
// authoritative: an upload whose Content-Type lies (AVIF bytes sent
// as image/png) is gated on the sniffed format. Like the inline path,
// keep the bytes as a path-referenced attachment instead of dropping
// them (best effort — the plain notice stands in when persisting
// fails).
mediaType = resolveEffectiveImageMime(mediaType, data);
if (!isModelAcceptedImageMime(mediaType)) {
const persisted = await persistAttachmentBytes(
data,
`${file.meta.id}-${sanitizeAttachmentName(file.meta.name)}`,
await resolveAttachmentsDir(),
);
content.push({
type: 'text',
text: persisted === null
? buildUnsupportedImageNotice(mediaType, file.meta.name)
: buildAttachedFileNotice(file.meta.name, mediaType, file.meta.size, persisted),
});
changed = true;
continue;
}
// Forward the canonical MIME (image/jpg → image/jpeg, case/whitespace)
// — strict provider whitelists reject the raw alias.
mediaType = normalizeImageMime(mediaType);
const compressed = await compressImageForModel(data, mediaType, {
telemetry: telemetryFor('prompt_file'),
});
if (compressed.changed) {
const dir = await resolveOriginalsDir();
const originalPath = await persistOriginalImage(data, mediaType, { dir });
content.push({
type: 'text',
text: buildImageCompressionCaption({
original: {
width: compressed.originalWidth,
height: compressed.originalHeight,
byteLength: compressed.originalByteLength,
mimeType: mediaType,
},
final: {
width: compressed.width,
height: compressed.height,
byteLength: compressed.finalByteLength,
mimeType: compressed.mimeType,
},
originalPath,
}),
});
}
bytes = compressed.data;
mediaType = compressed.mimeType;
content.push({
type: 'image',
source: {
kind: 'base64',
media_type: mediaType,
data: Buffer.from(bytes).toString('base64'),
},
});
changed = true;
continue;
}
// Uploaded video: materialize a local copy the model can open as a
// fallback, and carry the upload into context as an internal
// `kimi-file://<id>?path=<materialized path>` reference. The engine
// resolves it to a provider form (upload / inline / `<video path>` tag) at
// request time, so the edge never uploads and never blocks on the provider.
const cachePath = await materializeVideoToCache(file, cacheDir);
content.push({
type: 'video',
source: { kind: 'url', url: buildKimiFileUrl(file.meta.id, cachePath) },
});
changed = true;
}
return changed ? content : input;
}
async function materializeVideoToCache(file: GetResult, cacheDir: string): Promise<string> {
await mkdir(cacheDir, { recursive: true });
const ext = extname(file.meta.name) || (VIDEO_EXT_BY_MIME[file.meta.media_type.toLowerCase()] ?? '.bin');
const target = join(cacheDir, `${file.meta.id}${ext}`);
const info = await stat(target).catch(() => undefined);
if (info?.size === file.meta.size) return target;
await pipeline(file.stream(), createWriteStream(target));
return target;
}
const ATTACHMENT_NAME_MAX = 100;
/**
* Attachment file names are untrusted (the multipart filename / a wire field):
* strip path separators, control chars, and leading dots so the materialized
* file can never escape its directory or land as a hidden file, and cap the
* length so the path stays manageable.
*/
function sanitizeAttachmentName(name: string): string {
const cleaned = name
.replaceAll(/[\\/]/g, '_')
.replaceAll(/[\u0000-\u001F\u007F]/g, '')
.replace(/^\.+/, '')
.trim()
.slice(0, ATTACHMENT_NAME_MAX);
return cleaned.length > 0 ? cleaned : 'attachment';
}
/** Stream an uploaded file into `dir` as `<fileId>-<sanitized name>`. */
async function materializeAttachmentToDir(file: GetResult, dir: string): Promise<string> {
await mkdir(dir, { recursive: true });
const target = join(dir, `${file.meta.id}-${sanitizeAttachmentName(file.meta.name)}`);
const info = await stat(target).catch(() => undefined);
if (info?.size === file.meta.size) return target;
await pipeline(file.stream(), createWriteStream(target));
return target;
}
/**
* Write already-buffered attachment bytes into `dir` under `name` (the caller
* builds the name: file-id or content-hash prefixed). Best effort returns
* null instead of throwing so a prompt never fails over the persisted copy.
*/
async function persistAttachmentBytes(
bytes: Uint8Array,
name: string,
dir: string,
): Promise<string | null> {
try {
await mkdir(dir, { recursive: true });
const target = join(dir, name);
const info = await stat(target).catch(() => undefined);
if (info?.size !== bytes.length) await writeFile(target, bytes);
return target;
} catch {
return null;
}
}
/** Derive a file extension from an image MIME (`image/svg+xml` → `svg`). */
function imageExtensionForMime(mediaType: string): string {
const subtype = mediaType.split('/')[1]?.toLowerCase().split('+')[0] ?? '';
const ext = subtype.replaceAll(/[^a-z0-9-]/g, '');
return ext.length > 0 ? ext : 'img';
}
// This notice's exact shape is a client contract: kimi-web's messagesToTurns
// parses it (ATTACHED_FILE_NOTICE_RE) to rebuild the attachment chip after a
// resync — change the wording there too.
function buildAttachedFileNotice(name: string, mediaType: string, size: number, path: string): string {
return `Attached file "${name}" (${mediaType}, ${size} bytes): ${path} — open it with the Read tool`;
}
async function readFileOrStream(file: GetResult): Promise<Buffer> {
const chunks: Buffer[] = [];
for await (const chunk of file.stream()) {
chunks.push(Buffer.from(chunk as string | Uint8Array));
}
return Buffer.concat(chunks);
}
function assertMediaFile(file: GetResult, expected: 'image' | 'video'): void {
const prefix = expected === 'video' ? 'video/' : 'image/';
if (file.meta.media_type.toLowerCase().startsWith(prefix)) return;
throw new Error2(
'validation.failed',
`file ${file.meta.id} is ${file.meta.media_type}, not ${expected === 'video' ? 'a video' : 'an image'}`,
);
}

View file

@ -1,10 +1,12 @@
/**
* GET /v1/sessions/{session_id}/skills
* POST /v1/sessions/{session_id}/skills/{skill_name}:activate
* Body: `{ args?: string, attachments?: (ImageContent|VideoContent|FileContent)[] }`
*/
import { z } from 'zod';
import { fileContentSchema, imageContentSchema, videoContentSchema } from './message';
import { skillDescriptorSchema } from './skill';
export const listSkillsResponseSchema = z.object({
@ -12,9 +14,26 @@ export const listSkillsResponseSchema = z.object({
});
export type ListSkillsResponse = z.infer<typeof listSkillsResponseSchema>;
/**
* Attachment parts accepted on skill activation the media/file subset of
* the prompt submission's `MessageContent` (text stays in `args`).
*/
export const activateSkillAttachmentSchema = z.discriminatedUnion('type', [
imageContentSchema,
videoContentSchema,
fileContentSchema,
]);
export type ActivateSkillAttachment = z.infer<typeof activateSkillAttachmentSchema>;
export const activateSkillRequestSchema = z.object({
/** Raw argument string appended after the slash command, e.g. `/review --fix` → `--fix`. */
args: z.string().optional(),
/**
* Attachments carried into the skill turn's user message, in the same wire
* shape as prompt content. They are resolved by the shared prompt media
* pipeline and appended after the rendered skill prompt text part.
*/
attachments: z.array(activateSkillAttachmentSchema).optional(),
});
export type ActivateSkillRequest = z.infer<typeof activateSkillRequestSchema>;

View file

@ -5,11 +5,7 @@
* shapes from `packages/server/src/routes/prompts.ts`.
*/
import { createHash } from 'node:crypto';
import { createWriteStream } from 'node:fs';
import { mkdir, stat, writeFile } from 'node:fs/promises';
import { extname, join } from 'node:path';
import { pipeline } from 'node:stream/promises';
import { join } from 'node:path';
import {
IBootstrapService,
@ -22,7 +18,6 @@ import {
IEventService,
IFileService,
ISessionMetadata,
buildKimiFileUrl,
parseKimiFileUrl,
promptMetadataTextFromContentParts,
ProfileError,
@ -33,22 +28,10 @@ import {
resumeSessionById,
ITelemetryService,
applyPromptMetadataUpdate,
buildImageCompressionCaption,
buildUnsupportedImageNotice,
compressBase64ForModel,
compressImageForModel,
decodeBase64Prefix,
isError2,
Error2,
ErrorCodes,
isModelAcceptedImageMime,
normalizeImageMime,
persistOriginalImage,
resolveEffectiveImageMime,
sessionMediaOriginalsDir,
unsupportedImageMimeFromUrl,
type GetResult,
type ImageCompressionTelemetry,
type ISessionScopeHandle,
type Scope,
} from '@moonshot-ai/agent-core-v2';
@ -65,6 +48,11 @@ import {
import { z } from 'zod';
import { errEnvelope, okEnvelope } from '../envelope';
import {
assertPromptFileRefs,
contentToCoreParts,
resolvePromptMediaFiles,
} from '../lib/promptMedia';
import { requestLog } from '../lib/requestLog';
import { defineRoute } from '../middleware/defineRoute';
import { ensureMainAgent, MAIN_AGENT_ID } from '../transport/mainAgent';
@ -96,14 +84,6 @@ const sessionIdParamSchema = z.object({
const validationDetailsSchema = z.array(z.object({ path: z.string(), message: z.string() }));
const authProviderDetailsSchema = z.object({ provider_id: z.string() });
const authModelDetailsSchema = z.object({ model_id: z.string(), provider_id: z.string() }).partial();
const VIDEO_EXT_BY_MIME: Record<string, string> = {
'video/mp4': '.mp4',
'video/quicktime': '.mov',
'video/webm': '.webm',
'video/x-msvideo': '.avi',
'video/x-matroska': '.mkv',
'video/mpeg': '.mpeg',
};
async function resolveSession(core: Scope, sessionId: string): Promise<ISessionScopeHandle> {
// `resume` (not `get`) so a persisted-but-cold session — created by a previous
@ -181,23 +161,6 @@ async function applyProfileSelection(
return true;
}
/**
* Fail fast on stale or mis-kinded file references before anything
* session-scoped happens: a bad `file_id` (unknown, or a real file used with
* the wrong media kind, e.g. a PDF submitted as a video) must reject the
* request without creating the prompt agent and without touching the
* session's model/thinking/permission.
*/
async function assertPromptFileRefs(body: PromptSubmission, store: IFileService): Promise<void> {
for (const part of body.content) {
if (part.type === 'file') {
await store.get(part.file_id);
} else if ((part.type === 'image' || part.type === 'video') && part.source.kind === 'file') {
const file = await store.get(part.source.file_id);
assertMediaFile(file, part.type);
}
}
}
export function registerPromptsRoutes(app: PromptRouteHost, core: Scope): void {
const listRoute = defineRoute(
@ -249,7 +212,7 @@ export function registerPromptsRoutes(app: PromptRouteHost, core: Scope): void {
// Fail fast on stale file references before anything is resolved or
// mutated: a bad `file_id` must not create the agent, register `main`
// in session metadata, or touch the session's controls.
await assertPromptFileRefs(req.body, core.accessor.get(IFileService));
await assertPromptFileRefs(req.body.content, core.accessor.get(IFileService));
const resolved = await resolvePrompt(core, session_id, req.body.agent_id);
await resolved.auth.ensureReady();
@ -260,8 +223,8 @@ export function registerPromptsRoutes(app: PromptRouteHost, core: Scope): void {
// provider form (upload / inline / `<video path>` tag) at request
// time, so the edge no longer uploads.
const telemetry = core.accessor.get(ITelemetryService).withContext({ sessionId: session_id });
const resolvedBody = await resolvePromptMediaFiles(
req.body,
const resolvedContent = await resolvePromptMediaFiles(
req.body.content,
core.accessor.get(IFileService),
core.accessor.get(IBootstrapService).cacheDir,
{
@ -306,7 +269,7 @@ export function registerPromptsRoutes(app: PromptRouteHost, core: Scope): void {
throw error;
}
}
const parts = contentToCoreParts(resolvedBody.content);
const parts = contentToCoreParts(resolvedContent);
const session = await resolveSession(core, session_id);
await applyPromptMetadataUpdate({
metadata: session.accessor.get(ISessionMetadata),
@ -452,350 +415,6 @@ function corePartsToProtocol(content: readonly ContentPart[]): PromptSubmission[
return parts;
}
function contentToCoreParts(content: PromptSubmission['content']): ContentPart[] {
const parts: ContentPart[] = [];
for (const part of content) {
if (part.type === 'text') parts.push({ type: 'text', text: part.text });
else if (part.type === 'image' && part.source.kind === 'url') parts.push({ type: 'image_url', imageUrl: { url: part.source.url, id: part.source.id } });
else if (part.type === 'image' && part.source.kind === 'base64') parts.push({ type: 'image_url', imageUrl: { url: `data:${part.source.media_type};base64,${part.source.data}` } });
else if (part.type === 'video' && part.source.kind === 'url') parts.push({ type: 'video_url', videoUrl: { url: part.source.url, id: part.source.id } });
else if (part.type === 'video' && part.source.kind === 'base64') parts.push({ type: 'video_url', videoUrl: { url: `data:${part.source.media_type};base64,${part.source.data}` } });
}
return parts;
}
interface ResolvePromptMediaOptions {
/**
* Lazily resolve the session's media-originals dir for persisting the
* pre-compression bytes of inline base64 images. Only invoked when an image
* was actually compressed; a failure or undefined result falls back to the
* shared temp-dir cache.
*/
readonly resolveOriginalsDir?: () => Promise<string | undefined>;
/**
* Lazily resolve the session's attachments dir for materializing arbitrary
* file uploads (and image bytes the provider rejects) into a path the model
* can open with the Read tool. A failure or undefined result falls back to
* the shared cache dir.
*/
readonly resolveAttachmentsDir?: () => Promise<string | undefined>;
/** Report an `image_compress` event per compressed prompt image. */
readonly telemetry?: ITelemetryService;
}
async function resolvePromptMediaFiles(
body: PromptSubmission,
store: IFileService,
cacheDir: string,
options: ResolvePromptMediaOptions = {},
): Promise<PromptSubmission> {
let changed = false;
let originalsDir: string | undefined;
let originalsDirResolved = false;
const resolveOriginalsDir = async (): Promise<string | undefined> => {
if (!originalsDirResolved) {
originalsDirResolved = true;
originalsDir = await options.resolveOriginalsDir?.().catch(() => undefined);
}
return originalsDir;
};
let attachmentsDir: string | undefined;
let attachmentsDirResolved = false;
const resolveAttachmentsDir = async (): Promise<string> => {
if (!attachmentsDirResolved) {
attachmentsDirResolved = true;
attachmentsDir = await options.resolveAttachmentsDir?.().catch(() => undefined);
}
return attachmentsDir ?? cacheDir;
};
const telemetryFor = (source: string): ImageCompressionTelemetry | undefined =>
options.telemetry === undefined ? undefined : { client: options.telemetry, source };
const content: PromptSubmission['content'] = [];
for (const part of body.content) {
// Inline base64 image: compress the payload in place. This mirrors the v1
// server path for REST clients that submit an image without uploading it.
if (part.type === 'image' && part.source.kind === 'base64') {
// Formats the provider cannot accept must never enter the session
// history — one unsupported image_url makes every later request fail.
// The bytes are authoritative: an image labeled image/png that is
// actually AVIF is gated on the sniffed format, not the label. The
// bytes are still the user's content, though: persist them as a
// path-referenced attachment so the model can read and convert them
// itself (best effort — the plain notice stands in when persisting
// fails). Inline base64 has no original name, so the file is addressed
// by content hash with a name derived from the sniffed format.
const effectiveMime = resolveEffectiveImageMime(
part.source.media_type,
decodeBase64Prefix(part.source.data),
);
if (!isModelAcceptedImageMime(effectiveMime)) {
const bytes = Buffer.from(part.source.data, 'base64');
const name = `image.${imageExtensionForMime(effectiveMime)}`;
const persisted = await persistAttachmentBytes(
bytes,
`${createHash('sha256').update(bytes).digest('hex').slice(0, 32)}-${name}`,
await resolveAttachmentsDir(),
);
content.push({
type: 'text',
text: persisted === null
? buildUnsupportedImageNotice(effectiveMime)
: buildAttachedFileNotice(name, effectiveMime, bytes.length, persisted),
});
changed = true;
continue;
}
const canonicalMime = normalizeImageMime(effectiveMime);
const compressed = await compressBase64ForModel(part.source.data, canonicalMime, {
telemetry: telemetryFor('prompt_inline'),
});
if (compressed.changed) {
const dir = await resolveOriginalsDir();
const originalPath = await persistOriginalImage(
Buffer.from(part.source.data, 'base64'),
part.source.media_type,
{ dir },
);
content.push({
type: 'text',
text: buildImageCompressionCaption({
original: {
width: compressed.originalWidth,
height: compressed.originalHeight,
byteLength: compressed.originalByteLength,
mimeType: part.source.media_type,
},
final: {
width: compressed.width,
height: compressed.height,
byteLength: compressed.finalByteLength,
mimeType: compressed.mimeType,
},
originalPath,
}),
});
content.push({
type: 'image',
source: { kind: 'base64', media_type: compressed.mimeType, data: compressed.base64 },
});
changed = true;
} else {
content.push(part);
}
continue;
}
// Remote image URL: no bytes to sniff, so reject when its path extension
// names a format providers reject (e.g. a link ending in `.avif`) — the
// notice keeps the URL so the model can still fetch and convert the
// image. Extensionless / unknown URLs pass through to the provider and
// the 400 recovery. Image+URL parts that pass are re-emitted unchanged.
if (part.type === 'image' && part.source.kind === 'url') {
const extMime = unsupportedImageMimeFromUrl(part.source.url);
if (extMime !== null) {
content.push({ type: 'text', text: buildUnsupportedImageNotice(extMime, part.source.url) });
changed = true;
continue;
}
content.push(part);
continue;
}
// Arbitrary file attachment: materialize the uploaded bytes next to the
// session and replace the part with a path reference — the model opens it
// with the Read tool instead of receiving it as a media part.
if (part.type === 'file') {
const file = await store.get(part.file_id);
const attachedPath = await materializeAttachmentToDir(file, await resolveAttachmentsDir());
content.push({
type: 'text',
text: buildAttachedFileNotice(file.meta.name, file.meta.media_type, file.meta.size, attachedPath),
});
changed = true;
continue;
}
if ((part.type !== 'image' && part.type !== 'video') || part.source.kind !== 'file') {
content.push(part);
continue;
}
const file = await store.get(part.source.file_id);
assertMediaFile(file, part.type);
if (part.type === 'image') {
const data = await readFileOrStream(file);
let mediaType = file.meta.media_type;
let bytes: Uint8Array = data;
// Same format gate as the inline path above, and again the bytes are
// authoritative: an upload whose Content-Type lies (AVIF bytes sent
// as image/png) is gated on the sniffed format. Like the inline path,
// keep the bytes as a path-referenced attachment instead of dropping
// them (best effort — the plain notice stands in when persisting
// fails).
mediaType = resolveEffectiveImageMime(mediaType, data);
if (!isModelAcceptedImageMime(mediaType)) {
const persisted = await persistAttachmentBytes(
data,
`${file.meta.id}-${sanitizeAttachmentName(file.meta.name)}`,
await resolveAttachmentsDir(),
);
content.push({
type: 'text',
text: persisted === null
? buildUnsupportedImageNotice(mediaType, file.meta.name)
: buildAttachedFileNotice(file.meta.name, mediaType, file.meta.size, persisted),
});
changed = true;
continue;
}
// Forward the canonical MIME (image/jpg → image/jpeg, case/whitespace)
// — strict provider whitelists reject the raw alias.
mediaType = normalizeImageMime(mediaType);
const compressed = await compressImageForModel(data, mediaType, {
telemetry: telemetryFor('prompt_file'),
});
if (compressed.changed) {
const dir = await resolveOriginalsDir();
const originalPath = await persistOriginalImage(data, mediaType, { dir });
content.push({
type: 'text',
text: buildImageCompressionCaption({
original: {
width: compressed.originalWidth,
height: compressed.originalHeight,
byteLength: compressed.originalByteLength,
mimeType: mediaType,
},
final: {
width: compressed.width,
height: compressed.height,
byteLength: compressed.finalByteLength,
mimeType: compressed.mimeType,
},
originalPath,
}),
});
}
bytes = compressed.data;
mediaType = compressed.mimeType;
content.push({
type: 'image',
source: {
kind: 'base64',
media_type: mediaType,
data: Buffer.from(bytes).toString('base64'),
},
});
changed = true;
continue;
}
// Uploaded video: materialize a local copy the model can open as a
// fallback, and carry the upload into context as an internal
// `kimi-file://<id>?path=<materialized path>` reference. The engine
// resolves it to a provider form (upload / inline / `<video path>` tag) at
// request time, so the edge never uploads and never blocks on the provider.
const cachePath = await materializeVideoToCache(file, cacheDir);
content.push({
type: 'video',
source: { kind: 'url', url: buildKimiFileUrl(file.meta.id, cachePath) },
});
changed = true;
}
return changed ? { ...body, content } : body;
}
async function materializeVideoToCache(file: GetResult, cacheDir: string): Promise<string> {
await mkdir(cacheDir, { recursive: true });
const ext = extname(file.meta.name) || (VIDEO_EXT_BY_MIME[file.meta.media_type.toLowerCase()] ?? '.bin');
const target = join(cacheDir, `${file.meta.id}${ext}`);
const info = await stat(target).catch(() => undefined);
if (info?.size === file.meta.size) return target;
await pipeline(file.stream(), createWriteStream(target));
return target;
}
const ATTACHMENT_NAME_MAX = 100;
/**
* Attachment file names are untrusted (the multipart filename / a wire field):
* strip path separators, control chars, and leading dots so the materialized
* file can never escape its directory or land as a hidden file, and cap the
* length so the path stays manageable.
*/
function sanitizeAttachmentName(name: string): string {
const cleaned = name
.replaceAll(/[\\/]/g, '_')
.replaceAll(/[\u0000-\u001F\u007F]/g, '')
.replace(/^\.+/, '')
.trim()
.slice(0, ATTACHMENT_NAME_MAX);
return cleaned.length > 0 ? cleaned : 'attachment';
}
/** Stream an uploaded file into `dir` as `<fileId>-<sanitized name>`. */
async function materializeAttachmentToDir(file: GetResult, dir: string): Promise<string> {
await mkdir(dir, { recursive: true });
const target = join(dir, `${file.meta.id}-${sanitizeAttachmentName(file.meta.name)}`);
const info = await stat(target).catch(() => undefined);
if (info?.size === file.meta.size) return target;
await pipeline(file.stream(), createWriteStream(target));
return target;
}
/**
* Write already-buffered attachment bytes into `dir` under `name` (the caller
* builds the name: file-id or content-hash prefixed). Best effort returns
* null instead of throwing so a prompt never fails over the persisted copy.
*/
async function persistAttachmentBytes(
bytes: Uint8Array,
name: string,
dir: string,
): Promise<string | null> {
try {
await mkdir(dir, { recursive: true });
const target = join(dir, name);
const info = await stat(target).catch(() => undefined);
if (info?.size !== bytes.length) await writeFile(target, bytes);
return target;
} catch {
return null;
}
}
/** Derive a file extension from an image MIME (`image/svg+xml` → `svg`). */
function imageExtensionForMime(mediaType: string): string {
const subtype = mediaType.split('/')[1]?.toLowerCase().split('+')[0] ?? '';
const ext = subtype.replaceAll(/[^a-z0-9-]/g, '');
return ext.length > 0 ? ext : 'img';
}
// This notice's exact shape is a client contract: kimi-web's messagesToTurns
// parses it (ATTACHED_FILE_NOTICE_RE) to rebuild the attachment chip after a
// resync — change the wording there too.
function buildAttachedFileNotice(name: string, mediaType: string, size: number, path: string): string {
return `Attached file "${name}" (${mediaType}, ${size} bytes): ${path} — open it with the Read tool`;
}
async function readFileOrStream(file: GetResult): Promise<Buffer> {
const chunks: Buffer[] = [];
for await (const chunk of file.stream()) {
chunks.push(Buffer.from(chunk as string | Uint8Array));
}
return Buffer.concat(chunks);
}
function assertMediaFile(file: GetResult, expected: 'image' | 'video'): void {
const prefix = expected === 'video' ? 'video/' : 'image/';
if (file.meta.media_type.toLowerCase().startsWith(prefix)) return;
throw new Error2(
'validation.failed',
`file ${file.meta.id} is ${file.meta.media_type}, not ${expected === 'video' ? 'a video' : 'an image'}`,
);
}
function sendMappedError(
reply: { send(payload: unknown): unknown },

View file

@ -6,7 +6,7 @@
*
* GET /sessions/{session_id}/skills data: {skills: SkillDescriptor[]}
* GET /workspaces/{workspace_id}/skills data: {skills: SkillDescriptor[]}
* POST /sessions/{session_id}/skills/{skill_name}:activate body: {args?} data: {activated: true, skill_name}
* POST /sessions/{session_id}/skills/{skill_name}:activate body: {args?, attachments?} data: {activated: true, skill_name}
*
* The session list is session-scoped: the catalog is built per session
* (project skills are discovered from the session cwd), so it lives under
@ -46,6 +46,10 @@
* The edge then applies the prompt-metadata update
* (`applyPromptMetadataUpdate`) so a first `/<skill>`
* message titles the session, matching the native RPC path.
* Optional `attachments` (image/video/file parts, same wire
* shape as prompt content) run through the shared prompt
* media pipeline (`lib/promptMedia.ts`) and are appended to
* the activation's user message after the skill prompt.
*
* **Model projection**: `SkillDefinition` (v2) protocol `SkillDescriptor`,
* byte-for-byte with v1's `toProtocolSkill`
@ -59,6 +63,8 @@
* - 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`.
* - `file.not_found` (attachment) envelope `code: 40407 file.not_found`.
* - `validation.failed` (mis-kinded attachment) envelope `code: 40001 validation.failed`.
* - malformed `{tail}` (bad action, bare) envelope `code: 40001 validation.failed`.
* - other errors 50001 via the global `installErrorHandler`.
*
@ -72,20 +78,25 @@
import {
builtinProductSkillsEnabled,
visibleBuiltinSkills,
Error2,
ErrorCodes,
EXTRA_SKILL_DIRS_SECTION,
IAgentSkillService,
IBootstrapService,
IConfigService,
IEventService,
IFileService,
IPluginService,
ISessionContext,
ISessionIndex,
ISessionMetadata,
ISessionSkillCatalog,
ISkillDiscovery,
ITelemetryService,
IWorkspaceService,
InMemorySkillCatalog,
isError2,
isUserActivatableSkillType,
resumeSessionById,
MERGE_ALL_AVAILABLE_SKILLS_SECTION,
SKILL_SOURCE_PRIORITY,
@ -93,16 +104,24 @@ import {
configuredRoots,
projectRoots,
promptMetadataTextFromSkill,
sessionMediaOriginalsDir,
userRoots,
type ContentPart,
type ISessionScopeHandle,
type Scope,
type SkillDefinition,
type ExtraSkillDirsConfig,
type MergeAllAvailableSkillsConfig,
} from '@moonshot-ai/agent-core-v2';
import { join } from 'node:path';
import { z } from 'zod';
import { errEnvelope, okEnvelope } from '../envelope';
import {
assertPromptFileRefs,
contentToCoreParts,
resolvePromptMediaFiles,
} from '../lib/promptMedia';
import { requestLog } from '../lib/requestLog';
import { defineRoute } from '../middleware/defineRoute';
import { ensureMainAgent } from '../transport/mainAgent';
@ -257,6 +276,7 @@ export function registerSkillsRoutes(app: SkillsRouteHost, core: Scope): void {
[ErrorCode.SESSION_NOT_FOUND]: {},
[ErrorCode.SKILL_NOT_FOUND]: {},
[ErrorCode.SKILL_NOT_ACTIVATABLE]: {},
[ErrorCode.FILE_NOT_FOUND]: {},
},
description: 'Activate a skill in a session (REST analogue of the /<skill> slash command)',
tags: ['skills'],
@ -288,10 +308,48 @@ export function registerSkillsRoutes(app: SkillsRouteHost, core: Scope): void {
}
try {
// Attachments run through the same edge pipeline as prompt uploads
// (validate → materialize → convert) BEFORE the activation starts, so
// a bad file_id or an unreadable upload rejects the request without
// launching a skill turn.
const attachments = req.body.attachments ?? [];
const attachmentParts: ContentPart[] = [];
if (attachments.length > 0) {
// Validate the skill BEFORE materializing anything: an unknown or
// non-user-activatable name must fail without streaming upload bytes
// into the session/cache dirs. activate() re-validates on its own —
// this is only the edge fail-fast for the side-effecting pipeline.
const catalog = resolved.handle.accessor.get(ISessionSkillCatalog);
await catalog.ready;
const skill = catalog.catalog.getSkill(parsed.id);
if (skill === undefined) {
throw new Error2(ErrorCodes.SKILL_NOT_FOUND, `Skill "${parsed.id}" was not found`);
}
if (!isUserActivatableSkillType(skill.metadata.type)) {
throw new Error2(
ErrorCodes.SKILL_TYPE_UNSUPPORTED,
`Skill "${skill.name}" cannot be activated by the user`,
);
}
await assertPromptFileRefs(attachments, core.accessor.get(IFileService));
const telemetry = core.accessor.get(ITelemetryService).withContext({ sessionId: session_id });
const sessionDir = resolved.handle.accessor.get(ISessionContext).sessionDir;
const resolvedContent = await resolvePromptMediaFiles(
attachments,
core.accessor.get(IFileService),
core.accessor.get(IBootstrapService).cacheDir,
{
telemetry,
resolveOriginalsDir: async () => sessionMediaOriginalsDir(sessionDir),
resolveAttachmentsDir: async () => join(sessionDir, 'attachments'),
},
);
attachmentParts.push(...contentToCoreParts(resolvedContent));
}
const agent = await ensureMainAgent(resolved.handle);
await agent.accessor
.get(IAgentSkillService)
.activate({ name: parsed.id, args: req.body.args });
.activate({ name: parsed.id, args: req.body.args, content: attachmentParts });
// Keep the easy-title behavior of the native RPC / TUI path: a first
// `/<skill>` message titles the session (same as routes/prompts.ts).
await applyPromptMetadataUpdate(
@ -426,6 +484,13 @@ function sendMappedError(
case ErrorCodes.SKILL_TYPE_UNSUPPORTED:
reply.send(errEnvelope(ErrorCode.SKILL_NOT_ACTIVATABLE, err.message, requestId, err.stack));
return;
// Attachment pipeline failures (same mapping as routes/prompts.ts).
case ErrorCodes.FILE_NOT_FOUND:
reply.send(errEnvelope(ErrorCode.FILE_NOT_FOUND, err.message, requestId, err.stack));
return;
case ErrorCodes.VALIDATION_FAILED:
reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, err.message, requestId, err.stack));
return;
}
}
throw err;

View file

@ -21,7 +21,7 @@
* `routes/skills.ts`, which must match the session listing for the same cwd.
*/
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
@ -263,6 +263,106 @@ describe('server-v2 /api/v1 skills', () => {
expect(body.code).toBe(40001);
expect(body.msg).toMatch(/unsupported action/);
});
it('carries a file attachment into the activation message', async () => {
const id = await createSession();
await createMainAgent(id);
const noteBytes = Buffer.from('hello from the attachment');
const form = new FormData();
form.set('file', new Blob([noteBytes], { type: 'text/plain' }), 'note.txt');
const uploadRes = await fetch(`${base}/api/v1/files`, {
method: 'POST',
headers: authHeaders(server as RunningServer),
body: form,
} as never);
const uploaded = (await uploadRes.json()) as Envelope<{ id: string; size: number }>;
expect(uploaded.code).toBe(0);
const { body } = await postJson<{ activated: boolean; skill_name: string }>(
`/api/v1/sessions/${id}/skills/update-config:activate`,
{
args: '--help',
attachments: [
{
type: 'file',
file_id: uploaded.data.id,
name: 'note.txt',
media_type: 'text/plain',
size: noteBytes.length,
},
],
},
);
expect(body.code).toBe(0);
expect(body.data).toEqual({ activated: true, skill_name: 'update-config' });
// The activation's user message carries the rendered skill prompt
// followed by the materialized attachment's path notice — the same
// pipeline a prompt submission runs through.
const messages = await getJson<{
items: Array<{ role: string; content: Array<{ type: string; text?: string }> }>;
}>(`/api/v1/sessions/${id}/messages`);
const userMsg = messages.body.data.items.find((m) => m.role === 'user');
expect(userMsg).toBeDefined();
expect(userMsg!.content[0]?.type).toBe('text');
expect(userMsg!.content[0]?.text).toContain('User activated the skill "update-config"');
const notice = userMsg!.content[1];
expect(notice?.type).toBe('text');
expect(notice?.text).toContain('Attached file "note.txt"');
expect(notice?.text).toContain(`${noteBytes.length} bytes`);
const attachedPath = /bytes\): (.+) — open it with the Read tool$/.exec(notice?.text ?? '')?.[1];
expect(attachedPath).toBeDefined();
expect(attachedPath).toContain('/attachments/');
expect(await readFile(attachedPath!)).toEqual(noteBytes);
});
it('rejects an activation with a stale attachment file_id (40407)', async () => {
const id = await createSession();
await createMainAgent(id);
const { body } = await postJson<null>(
`/api/v1/sessions/${id}/skills/update-config:activate`,
{
attachments: [
{ type: 'file', file_id: 'f_does_not_exist', name: 'x.txt', media_type: 'text/plain', size: 1 },
],
},
);
expect(body.code).toBe(40407);
});
it('rejects an unknown skill with attachments before materializing them (40415)', async () => {
const id = await createSession();
await createMainAgent(id);
// A real upload, so skipping the skill check would stream its bytes
// into the session attachments dir.
const noteBytes = Buffer.from('must never be materialized');
const form = new FormData();
form.set('file', new Blob([noteBytes], { type: 'text/plain' }), 'note.txt');
const uploadRes = await fetch(`${base}/api/v1/files`, {
method: 'POST',
headers: authHeaders(server as RunningServer),
body: form,
} as never);
const uploaded = (await uploadRes.json()) as Envelope<{ id: string }>;
expect(uploaded.code).toBe(0);
const { body } = await postJson<null>(
`/api/v1/sessions/${id}/skills/does-not-exist:activate`,
{
attachments: [
{ type: 'file', file_id: uploaded.data.id, name: 'note.txt', media_type: 'text/plain', size: noteBytes.length },
],
},
);
expect(body.code).toBe(40415);
// The rejected activation left no materialized attachments on disk.
const sessionTree = await readdir(join(home as string, 'sessions'), { recursive: true });
expect(sessionTree.filter((entry) => entry.includes('attachments'))).toEqual([]);
});
});
describe('GET /api/v1/workspaces/{wid}/skills', () => {

View file

@ -28,7 +28,13 @@ export const toolResultContentSchema = z.object({
export type ToolResultContent = z.infer<typeof toolResultContentSchema>;
export const imageSourceSchema = z.discriminatedUnion('kind', [
z.object({ kind: z.literal('url'), url: z.string().min(1) }),
z.object({
kind: z.literal('url'),
url: z.string().min(1),
// Provider-issued file id behind a reference such as `ms://…` — forwarded
// when the provider keys media by id. Matches the kap-server wire schema.
id: z.string().min(1).optional(),
}),
z.object({
kind: z.literal('base64'),
media_type: z.string().min(1),

View file

@ -4,7 +4,7 @@
* Errors: 40401 session.not_found
*
* POST /v1/sessions/{session_id}/skills/{skill_name}:activate
* Body: `{ args?: string }`
* Body: `{ args?: string, attachments?: (ImageContent|VideoContent|FileContent)[] }`
* Response data: `{ activated: true, skill_name: string }`
* Errors: 40401 session.not_found, 40415 skill.not_found,
* 40912 skill.not_activatable
@ -12,6 +12,7 @@
import { z } from 'zod';
import { fileContentSchema, imageContentSchema, videoContentSchema } from '../message';
import { skillDescriptorSchema } from '../skill';
export const listSkillsResponseSchema = z.object({
@ -19,9 +20,26 @@ export const listSkillsResponseSchema = z.object({
});
export type ListSkillsResponse = z.infer<typeof listSkillsResponseSchema>;
/**
* Attachment parts accepted on skill activation the media/file subset of
* the prompt submission's `MessageContent` (text stays in `args`).
*/
export const activateSkillAttachmentSchema = z.discriminatedUnion('type', [
imageContentSchema,
videoContentSchema,
fileContentSchema,
]);
export type ActivateSkillAttachment = z.infer<typeof activateSkillAttachmentSchema>;
export const activateSkillRequestSchema = z.object({
/** Raw argument string appended after the slash command, e.g. `/review --fix` → `--fix`. */
args: z.string().optional(),
/**
* Attachments carried into the skill turn's user message, in the same wire
* shape as prompt content. They are resolved by the shared prompt media
* pipeline and appended after the rendered skill prompt text part.
*/
attachments: z.array(activateSkillAttachmentSchema).optional(),
});
export type ActivateSkillRequest = z.infer<typeof activateSkillRequestSchema>;