mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-06 23:36:06 +00:00
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:
parent
4d39f4fa6f
commit
cfd14a1fe2
9 changed files with 671 additions and 400 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
430
packages/kap-server/src/lib/promptMedia.ts
Normal file
430
packages/kap-server/src/lib/promptMedia.ts
Normal 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'}`,
|
||||
);
|
||||
}
|
||||
|
|
@ -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>;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue