mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-29 12:03:40 +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 { createDecorator } from "#/_base/di/instantiation";
|
||||||
import type { SkillActivationOrigin } from '#/agent/contextMemory/types';
|
import type { SkillActivationOrigin } from '#/agent/contextMemory/types';
|
||||||
import type { Turn } from '#/agent/loop/loop';
|
import type { Turn } from '#/agent/loop/loop';
|
||||||
|
import type { ContentPart } from '#/kosong/contract/message';
|
||||||
|
|
||||||
export interface SkillActivationInput {
|
export interface SkillActivationInput {
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly args?: string;
|
readonly args?: string;
|
||||||
|
readonly content?: readonly ContentPart[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IAgentSkillService {
|
export interface IAgentSkillService {
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,11 @@
|
||||||
* records the activation as a `skill.activate` fact through `wire.dispatch`
|
* records the activation as a `skill.activate` fact through `wire.dispatch`
|
||||||
* (a stateless, identity-apply Op), derives the `skill.activated` event
|
* (a stateless, identity-apply Op), derives the `skill.activated` event
|
||||||
* through the Op's `toEvent`, drives user-slash activations into a new turn via
|
* through the Op's `toEvent`, drives user-slash activations into a new turn via
|
||||||
* `prompt`, and reports `skill_invoked` / `flow_invoked` through `telemetry`.
|
* `prompt` (attachment parts from the caller ride the same user message after
|
||||||
* `wire.replay` reapplies the fact as a no-op, so neither the event nor
|
* the rendered prompt), and reports `skill_invoked` / `flow_invoked` through
|
||||||
* telemetry fires on resume (matching the former `restoring` guard). Bound at
|
* `telemetry`. `wire.replay` reapplies the fact as a no-op, so neither the
|
||||||
* Agent scope.
|
* event nor telemetry fires on resume (matching the former `restoring` guard).
|
||||||
|
* Bound at Agent scope.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
@ -70,6 +71,7 @@ export class AgentSkillService extends Service implements IAgentSkillService {
|
||||||
skillDir: skill.dir,
|
skillDir: skill.dir,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
...(input.content ?? []),
|
||||||
];
|
];
|
||||||
|
|
||||||
const turn = await this.recordActivation(
|
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
|
* GET /v1/sessions/{session_id}/skills
|
||||||
* POST /v1/sessions/{session_id}/skills/{skill_name}:activate
|
* POST /v1/sessions/{session_id}/skills/{skill_name}:activate
|
||||||
|
* Body: `{ args?: string, attachments?: (ImageContent|VideoContent|FileContent)[] }`
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { fileContentSchema, imageContentSchema, videoContentSchema } from './message';
|
||||||
import { skillDescriptorSchema } from './skill';
|
import { skillDescriptorSchema } from './skill';
|
||||||
|
|
||||||
export const listSkillsResponseSchema = z.object({
|
export const listSkillsResponseSchema = z.object({
|
||||||
|
|
@ -12,9 +14,26 @@ export const listSkillsResponseSchema = z.object({
|
||||||
});
|
});
|
||||||
export type ListSkillsResponse = z.infer<typeof listSkillsResponseSchema>;
|
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({
|
export const activateSkillRequestSchema = z.object({
|
||||||
/** Raw argument string appended after the slash command, e.g. `/review --fix` → `--fix`. */
|
/** Raw argument string appended after the slash command, e.g. `/review --fix` → `--fix`. */
|
||||||
args: z.string().optional(),
|
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>;
|
export type ActivateSkillRequest = z.infer<typeof activateSkillRequestSchema>;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,7 @@
|
||||||
* shapes from `packages/server/src/routes/prompts.ts`.
|
* shapes from `packages/server/src/routes/prompts.ts`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createHash } from 'node:crypto';
|
import { join } from 'node:path';
|
||||||
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 {
|
import {
|
||||||
IBootstrapService,
|
IBootstrapService,
|
||||||
|
|
@ -22,7 +18,6 @@ import {
|
||||||
IEventService,
|
IEventService,
|
||||||
IFileService,
|
IFileService,
|
||||||
ISessionMetadata,
|
ISessionMetadata,
|
||||||
buildKimiFileUrl,
|
|
||||||
parseKimiFileUrl,
|
parseKimiFileUrl,
|
||||||
promptMetadataTextFromContentParts,
|
promptMetadataTextFromContentParts,
|
||||||
ProfileError,
|
ProfileError,
|
||||||
|
|
@ -33,22 +28,10 @@ import {
|
||||||
resumeSessionById,
|
resumeSessionById,
|
||||||
ITelemetryService,
|
ITelemetryService,
|
||||||
applyPromptMetadataUpdate,
|
applyPromptMetadataUpdate,
|
||||||
buildImageCompressionCaption,
|
|
||||||
buildUnsupportedImageNotice,
|
|
||||||
compressBase64ForModel,
|
|
||||||
compressImageForModel,
|
|
||||||
decodeBase64Prefix,
|
|
||||||
isError2,
|
isError2,
|
||||||
Error2,
|
Error2,
|
||||||
ErrorCodes,
|
ErrorCodes,
|
||||||
isModelAcceptedImageMime,
|
|
||||||
normalizeImageMime,
|
|
||||||
persistOriginalImage,
|
|
||||||
resolveEffectiveImageMime,
|
|
||||||
sessionMediaOriginalsDir,
|
sessionMediaOriginalsDir,
|
||||||
unsupportedImageMimeFromUrl,
|
|
||||||
type GetResult,
|
|
||||||
type ImageCompressionTelemetry,
|
|
||||||
type ISessionScopeHandle,
|
type ISessionScopeHandle,
|
||||||
type Scope,
|
type Scope,
|
||||||
} from '@moonshot-ai/agent-core-v2';
|
} from '@moonshot-ai/agent-core-v2';
|
||||||
|
|
@ -65,6 +48,11 @@ import {
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { errEnvelope, okEnvelope } from '../envelope';
|
import { errEnvelope, okEnvelope } from '../envelope';
|
||||||
|
import {
|
||||||
|
assertPromptFileRefs,
|
||||||
|
contentToCoreParts,
|
||||||
|
resolvePromptMediaFiles,
|
||||||
|
} from '../lib/promptMedia';
|
||||||
import { requestLog } from '../lib/requestLog';
|
import { requestLog } from '../lib/requestLog';
|
||||||
import { defineRoute } from '../middleware/defineRoute';
|
import { defineRoute } from '../middleware/defineRoute';
|
||||||
import { ensureMainAgent, MAIN_AGENT_ID } from '../transport/mainAgent';
|
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 validationDetailsSchema = z.array(z.object({ path: z.string(), message: z.string() }));
|
||||||
const authProviderDetailsSchema = z.object({ provider_id: z.string() });
|
const authProviderDetailsSchema = z.object({ provider_id: z.string() });
|
||||||
const authModelDetailsSchema = z.object({ model_id: z.string(), provider_id: z.string() }).partial();
|
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> {
|
async function resolveSession(core: Scope, sessionId: string): Promise<ISessionScopeHandle> {
|
||||||
// `resume` (not `get`) so a persisted-but-cold session — created by a previous
|
// `resume` (not `get`) so a persisted-but-cold session — created by a previous
|
||||||
|
|
@ -181,23 +161,6 @@ async function applyProfileSelection(
|
||||||
return true;
|
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 {
|
export function registerPromptsRoutes(app: PromptRouteHost, core: Scope): void {
|
||||||
const listRoute = defineRoute(
|
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
|
// Fail fast on stale file references before anything is resolved or
|
||||||
// mutated: a bad `file_id` must not create the agent, register `main`
|
// mutated: a bad `file_id` must not create the agent, register `main`
|
||||||
// in session metadata, or touch the session's controls.
|
// 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);
|
const resolved = await resolvePrompt(core, session_id, req.body.agent_id);
|
||||||
await resolved.auth.ensureReady();
|
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
|
// provider form (upload / inline / `<video path>` tag) at request
|
||||||
// time, so the edge no longer uploads.
|
// time, so the edge no longer uploads.
|
||||||
const telemetry = core.accessor.get(ITelemetryService).withContext({ sessionId: session_id });
|
const telemetry = core.accessor.get(ITelemetryService).withContext({ sessionId: session_id });
|
||||||
const resolvedBody = await resolvePromptMediaFiles(
|
const resolvedContent = await resolvePromptMediaFiles(
|
||||||
req.body,
|
req.body.content,
|
||||||
core.accessor.get(IFileService),
|
core.accessor.get(IFileService),
|
||||||
core.accessor.get(IBootstrapService).cacheDir,
|
core.accessor.get(IBootstrapService).cacheDir,
|
||||||
{
|
{
|
||||||
|
|
@ -306,7 +269,7 @@ export function registerPromptsRoutes(app: PromptRouteHost, core: Scope): void {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const parts = contentToCoreParts(resolvedBody.content);
|
const parts = contentToCoreParts(resolvedContent);
|
||||||
const session = await resolveSession(core, session_id);
|
const session = await resolveSession(core, session_id);
|
||||||
await applyPromptMetadataUpdate({
|
await applyPromptMetadataUpdate({
|
||||||
metadata: session.accessor.get(ISessionMetadata),
|
metadata: session.accessor.get(ISessionMetadata),
|
||||||
|
|
@ -452,350 +415,6 @@ function corePartsToProtocol(content: readonly ContentPart[]): PromptSubmission[
|
||||||
return parts;
|
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(
|
function sendMappedError(
|
||||||
reply: { send(payload: unknown): unknown },
|
reply: { send(payload: unknown): unknown },
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
*
|
*
|
||||||
* GET /sessions/{session_id}/skills data: {skills: SkillDescriptor[]}
|
* GET /sessions/{session_id}/skills data: {skills: SkillDescriptor[]}
|
||||||
* GET /workspaces/{workspace_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
|
* The session list is session-scoped: the catalog is built per session
|
||||||
* (project skills are discovered from the session cwd), so it lives under
|
* (project skills are discovered from the session cwd), so it lives under
|
||||||
|
|
@ -46,6 +46,10 @@
|
||||||
* The edge then applies the prompt-metadata update
|
* The edge then applies the prompt-metadata update
|
||||||
* (`applyPromptMetadataUpdate`) so a first `/<skill>`
|
* (`applyPromptMetadataUpdate`) so a first `/<skill>`
|
||||||
* message titles the session, matching the native RPC path.
|
* 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`,
|
* **Model projection**: `SkillDefinition` (v2) → protocol `SkillDescriptor`,
|
||||||
* byte-for-byte with v1's `toProtocolSkill`
|
* byte-for-byte with v1's `toProtocolSkill`
|
||||||
|
|
@ -59,6 +63,8 @@
|
||||||
* - not live / unknown session → envelope `code: 40401 session.not_found` (see gate above).
|
* - 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.not_found` / `skill.name_empty` → envelope `code: 40415 skill.not_found`.
|
||||||
* - `skill.type_unsupported` → envelope `code: 40912 skill.not_activatable`.
|
* - `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`.
|
* - malformed `{tail}` (bad action, bare) → envelope `code: 40001 validation.failed`.
|
||||||
* - other errors → 50001 via the global `installErrorHandler`.
|
* - other errors → 50001 via the global `installErrorHandler`.
|
||||||
*
|
*
|
||||||
|
|
@ -72,20 +78,25 @@
|
||||||
import {
|
import {
|
||||||
builtinProductSkillsEnabled,
|
builtinProductSkillsEnabled,
|
||||||
visibleBuiltinSkills,
|
visibleBuiltinSkills,
|
||||||
|
Error2,
|
||||||
ErrorCodes,
|
ErrorCodes,
|
||||||
EXTRA_SKILL_DIRS_SECTION,
|
EXTRA_SKILL_DIRS_SECTION,
|
||||||
IAgentSkillService,
|
IAgentSkillService,
|
||||||
IBootstrapService,
|
IBootstrapService,
|
||||||
IConfigService,
|
IConfigService,
|
||||||
IEventService,
|
IEventService,
|
||||||
|
IFileService,
|
||||||
IPluginService,
|
IPluginService,
|
||||||
|
ISessionContext,
|
||||||
ISessionIndex,
|
ISessionIndex,
|
||||||
ISessionMetadata,
|
ISessionMetadata,
|
||||||
ISessionSkillCatalog,
|
ISessionSkillCatalog,
|
||||||
ISkillDiscovery,
|
ISkillDiscovery,
|
||||||
|
ITelemetryService,
|
||||||
IWorkspaceService,
|
IWorkspaceService,
|
||||||
InMemorySkillCatalog,
|
InMemorySkillCatalog,
|
||||||
isError2,
|
isError2,
|
||||||
|
isUserActivatableSkillType,
|
||||||
resumeSessionById,
|
resumeSessionById,
|
||||||
MERGE_ALL_AVAILABLE_SKILLS_SECTION,
|
MERGE_ALL_AVAILABLE_SKILLS_SECTION,
|
||||||
SKILL_SOURCE_PRIORITY,
|
SKILL_SOURCE_PRIORITY,
|
||||||
|
|
@ -93,16 +104,24 @@ import {
|
||||||
configuredRoots,
|
configuredRoots,
|
||||||
projectRoots,
|
projectRoots,
|
||||||
promptMetadataTextFromSkill,
|
promptMetadataTextFromSkill,
|
||||||
|
sessionMediaOriginalsDir,
|
||||||
userRoots,
|
userRoots,
|
||||||
|
type ContentPart,
|
||||||
type ISessionScopeHandle,
|
type ISessionScopeHandle,
|
||||||
type Scope,
|
type Scope,
|
||||||
type SkillDefinition,
|
type SkillDefinition,
|
||||||
type ExtraSkillDirsConfig,
|
type ExtraSkillDirsConfig,
|
||||||
type MergeAllAvailableSkillsConfig,
|
type MergeAllAvailableSkillsConfig,
|
||||||
} from '@moonshot-ai/agent-core-v2';
|
} from '@moonshot-ai/agent-core-v2';
|
||||||
|
import { join } from 'node:path';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { errEnvelope, okEnvelope } from '../envelope';
|
import { errEnvelope, okEnvelope } from '../envelope';
|
||||||
|
import {
|
||||||
|
assertPromptFileRefs,
|
||||||
|
contentToCoreParts,
|
||||||
|
resolvePromptMediaFiles,
|
||||||
|
} from '../lib/promptMedia';
|
||||||
import { requestLog } from '../lib/requestLog';
|
import { requestLog } from '../lib/requestLog';
|
||||||
import { defineRoute } from '../middleware/defineRoute';
|
import { defineRoute } from '../middleware/defineRoute';
|
||||||
import { ensureMainAgent } from '../transport/mainAgent';
|
import { ensureMainAgent } from '../transport/mainAgent';
|
||||||
|
|
@ -257,6 +276,7 @@ export function registerSkillsRoutes(app: SkillsRouteHost, core: Scope): void {
|
||||||
[ErrorCode.SESSION_NOT_FOUND]: {},
|
[ErrorCode.SESSION_NOT_FOUND]: {},
|
||||||
[ErrorCode.SKILL_NOT_FOUND]: {},
|
[ErrorCode.SKILL_NOT_FOUND]: {},
|
||||||
[ErrorCode.SKILL_NOT_ACTIVATABLE]: {},
|
[ErrorCode.SKILL_NOT_ACTIVATABLE]: {},
|
||||||
|
[ErrorCode.FILE_NOT_FOUND]: {},
|
||||||
},
|
},
|
||||||
description: 'Activate a skill in a session (REST analogue of the /<skill> slash command)',
|
description: 'Activate a skill in a session (REST analogue of the /<skill> slash command)',
|
||||||
tags: ['skills'],
|
tags: ['skills'],
|
||||||
|
|
@ -288,10 +308,48 @@ export function registerSkillsRoutes(app: SkillsRouteHost, core: Scope): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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);
|
const agent = await ensureMainAgent(resolved.handle);
|
||||||
await agent.accessor
|
await agent.accessor
|
||||||
.get(IAgentSkillService)
|
.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
|
// Keep the easy-title behavior of the native RPC / TUI path: a first
|
||||||
// `/<skill>` message titles the session (same as routes/prompts.ts).
|
// `/<skill>` message titles the session (same as routes/prompts.ts).
|
||||||
await applyPromptMetadataUpdate(
|
await applyPromptMetadataUpdate(
|
||||||
|
|
@ -426,6 +484,13 @@ function sendMappedError(
|
||||||
case ErrorCodes.SKILL_TYPE_UNSUPPORTED:
|
case ErrorCodes.SKILL_TYPE_UNSUPPORTED:
|
||||||
reply.send(errEnvelope(ErrorCode.SKILL_NOT_ACTIVATABLE, err.message, requestId, err.stack));
|
reply.send(errEnvelope(ErrorCode.SKILL_NOT_ACTIVATABLE, err.message, requestId, err.stack));
|
||||||
return;
|
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;
|
throw err;
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
* `routes/skills.ts`, which must match the session listing for the same cwd.
|
* `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 { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
|
@ -263,6 +263,106 @@ describe('server-v2 /api/v1 skills', () => {
|
||||||
expect(body.code).toBe(40001);
|
expect(body.code).toBe(40001);
|
||||||
expect(body.msg).toMatch(/unsupported action/);
|
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', () => {
|
describe('GET /api/v1/workspaces/{wid}/skills', () => {
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,13 @@ export const toolResultContentSchema = z.object({
|
||||||
export type ToolResultContent = z.infer<typeof toolResultContentSchema>;
|
export type ToolResultContent = z.infer<typeof toolResultContentSchema>;
|
||||||
|
|
||||||
export const imageSourceSchema = z.discriminatedUnion('kind', [
|
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({
|
z.object({
|
||||||
kind: z.literal('base64'),
|
kind: z.literal('base64'),
|
||||||
media_type: z.string().min(1),
|
media_type: z.string().min(1),
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
* Errors: 40401 session.not_found
|
* Errors: 40401 session.not_found
|
||||||
*
|
*
|
||||||
* POST /v1/sessions/{session_id}/skills/{skill_name}:activate
|
* 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 }`
|
* Response data: `{ activated: true, skill_name: string }`
|
||||||
* Errors: 40401 session.not_found, 40415 skill.not_found,
|
* Errors: 40401 session.not_found, 40415 skill.not_found,
|
||||||
* 40912 skill.not_activatable
|
* 40912 skill.not_activatable
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { fileContentSchema, imageContentSchema, videoContentSchema } from '../message';
|
||||||
import { skillDescriptorSchema } from '../skill';
|
import { skillDescriptorSchema } from '../skill';
|
||||||
|
|
||||||
export const listSkillsResponseSchema = z.object({
|
export const listSkillsResponseSchema = z.object({
|
||||||
|
|
@ -19,9 +20,26 @@ export const listSkillsResponseSchema = z.object({
|
||||||
});
|
});
|
||||||
export type ListSkillsResponse = z.infer<typeof listSkillsResponseSchema>;
|
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({
|
export const activateSkillRequestSchema = z.object({
|
||||||
/** Raw argument string appended after the slash command, e.g. `/review --fix` → `--fix`. */
|
/** Raw argument string appended after the slash command, e.g. `/review --fix` → `--fix`. */
|
||||||
args: z.string().optional(),
|
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>;
|
export type ActivateSkillRequest = z.infer<typeof activateSkillRequestSchema>;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue