Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/inline-multi-skill-sdk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code-sdk": minor
---

Add `session.promptWithSkills(input, skills)` to submit one prompt with one or more skill activations bundled into the same user message — one turn, one undo unit (v2 engine only; rejects on the v1 engine).
24 changes: 24 additions & 0 deletions packages/agent-core-v2/docs/state-manifest.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -733,6 +733,14 @@ export interface AgentStateSnapshot {
readonly turnId: number;
readonly origin: /* PromptOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ /* UserPromptOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ {
readonly kind: 'user';
readonly skillActivations?: readonly /* BundledSkillActivation — packages/agent-core-v2/src/agent/contextMemory/types.ts */ {
readonly activationId: string;
readonly skillName: string;
readonly skillArgs?: string;
readonly skillType?: string;
readonly skillPath?: string;
readonly skillSource?: 'project' | 'user' | 'extra' | 'builtin';
}[];
} | /* SkillActivationOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ {
readonly kind: 'skill_activation';
readonly activationId: string;
Expand Down Expand Up @@ -858,6 +866,14 @@ export interface AgentStateSnapshot {
turnId: number;
origin: /* PromptOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ /* UserPromptOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ {
readonly kind: 'user';
readonly skillActivations?: readonly /* BundledSkillActivation — packages/agent-core-v2/src/agent/contextMemory/types.ts */ {
readonly activationId: string;
readonly skillName: string;
readonly skillArgs?: string;
readonly skillType?: string;
readonly skillPath?: string;
readonly skillSource?: 'project' | 'user' | 'extra' | 'builtin';
}[];
} | /* SkillActivationOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ {
readonly kind: 'skill_activation';
readonly activationId: string;
Expand Down Expand Up @@ -915,6 +931,14 @@ export interface AgentStateSnapshot {
readonly turnId: number;
readonly origin: /* PromptOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ /* UserPromptOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ {
readonly kind: 'user';
readonly skillActivations?: readonly /* BundledSkillActivation — packages/agent-core-v2/src/agent/contextMemory/types.ts */ {
readonly activationId: string;
readonly skillName: string;
readonly skillArgs?: string;
readonly skillType?: string;
readonly skillPath?: string;
readonly skillSource?: 'project' | 'user' | 'extra' | 'builtin';
}[];
} | /* SkillActivationOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ {
readonly kind: 'skill_activation';
readonly activationId: string;
Expand Down
10 changes: 10 additions & 0 deletions packages/agent-core-v2/src/agent/contextMemory/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,20 @@ export type SkillSource = 'project' | 'user' | 'extra' | 'builtin';

export interface UserPromptOrigin {
readonly kind: 'user';
readonly skillActivations?: readonly BundledSkillActivation[];
}

export const USER_PROMPT_ORIGIN: UserPromptOrigin = { kind: 'user' };

export interface BundledSkillActivation {
readonly activationId: string;
readonly skillName: string;
readonly skillArgs?: string;
readonly skillType?: string;
readonly skillPath?: string;
readonly skillSource?: SkillSource;
}

export interface SkillActivationOrigin {
readonly kind: 'skill_activation';
readonly activationId: string;
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-core-v2/src/agent/loop/loopService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -474,7 +474,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService {
type: 'turn.started',
turnId: job.turn.id,
origin,
prompt: isDisplayablePromptOrigin(origin) ? turnPromptText(job.seed.input) : undefined,
prompt: isDisplayablePromptOrigin(origin) ? turnPromptText(job.seed.input, origin) : undefined,
});
void this.runTurn(job.turn, job.ready).then(job.result.resolve, job.result.reject);
}
Expand Down
11 changes: 9 additions & 2 deletions packages/agent-core-v2/src/agent/loop/turnEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
* prompt rides the event only for displayable user origins
* ({@link isDisplayablePromptOrigin}) — a system-triggered turn (goal
* continuation, subagent run, cron…) has internal steering text as its input,
* which must never surface in transcripts.
* which must never surface in transcripts. When the turn's prompt bundles
* skill activations, their rendered blocks (prepended to the content, one
* text part per skill) are excluded from the extracted text.
*/

import type { KimiErrorPayload } from '#/_base/errors/serialize';
Expand All @@ -35,9 +37,14 @@ export interface TurnStartedEvent {
readonly prompt?: string;
}

export function turnPromptText(input: readonly ContentPart[]): string | undefined {
export function turnPromptText(
input: readonly ContentPart[],
origin?: PromptOrigin,
): string | undefined {
const bundledBlocks = origin?.kind === 'user' ? (origin.skillActivations?.length ?? 0) : 0;
const text = input
.filter((part): part is TextPart => part.type === 'text')
.slice(bundledBlocks)
.map((part) => part.text)
.join('');
return text.length > 0 ? text : undefined;
Expand Down
19 changes: 17 additions & 2 deletions packages/agent-core-v2/src/agent/skill/skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@
* edge-resolved attachment parts (`content`) that the activation appends after
* the rendered skill prompt in its user message. `IAgentSkillService`
* delivers activations (`activate` — steered into the running turn when busy,
* launched as a fresh turn when idle) and records model-tool activations
* without a turn (`recordModelToolActivation`). Bound at Agent scope.
* launched as a fresh turn when idle), submits one prompt with one or more
* skill activations bundled into the same user message (`promptWithSkills` —
* the rendered skill blocks precede the caller's parts in the content and the
* activation metadata rides the prompt's origin, so the bundle is a single
* turn and a single undo unit), and records model-tool activations without a
* turn (`recordModelToolActivation`). Bound at Agent scope.
*/

import { createDecorator } from "#/_base/di/instantiation";
Expand All @@ -20,10 +24,21 @@ export interface SkillActivationInput {
readonly content?: readonly ContentPart[];
}

export interface PromptSkillActivation {
readonly name: string;
readonly args?: string;
}

export interface PromptWithSkillsInput {
readonly input: readonly ContentPart[];
readonly skills: readonly PromptSkillActivation[];
}

export interface IAgentSkillService {
readonly _serviceBrand: undefined;

activate(input: SkillActivationInput): Promise<PromptLaunchResult>;
promptWithSkills(input: PromptWithSkillsInput): Promise<PromptLaunchResult | undefined>;
recordModelToolActivation(origin: SkillActivationOrigin): void;
}

Expand Down
121 changes: 114 additions & 7 deletions packages/agent-core-v2/src/agent/skill/skillService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,16 @@
* message after the rendered prompt). It settles `{turn_id}` for the caller,
* persists the derived title/lastPrompt through `sessionMetadata` for the
* main agent only (publishing the live update through `event`), 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.
* `skill_invoked` / `flow_invoked` through `telemetry`. `promptWithSkills`
* bundles one or more skill activations into the prompt's own user message:
* the rendered skill blocks precede the caller's parts in the content and
* each activation's metadata rides the prompt origin's `skillActivations`,
* so the bundle launches as a single turn and undoes as a single anchor;
* every skill is validated before anything is recorded, so an invalid name
* or an empty skill list rejects the whole submission. The fact is transient
* (`persist: false`), so neither the event nor telemetry fires on resume —
* bundled activations are rebuilt from the prompt origin instead. Bound at
* Agent scope.
*/

import { randomUUID } from 'node:crypto';
Expand All @@ -22,8 +29,13 @@ import { ScopeActivation, registerScopedService } from '#/_base/di/scope';

import type { ContentPart } from '#/kosong/contract/message';

import type { ContextMessage, SkillActivationOrigin } from '#/agent/contextMemory/types';
import type {
BundledSkillActivation,
ContextMessage,
SkillActivationOrigin,
} from '#/agent/contextMemory/types';
import { promptMetadataTextFromSkill, renderUserSlashSkillPrompt } from './prompt';
import { promptMetadataTextFromContentParts } from '#/agent/prompt/promptMetadataText';
import { ISessionContext } from '#/session/sessionContext/sessionContext';
import { Service } from '#/_base/di/service';
import { ErrorCodes, Error2 } from '#/errors';
Expand All @@ -32,7 +44,12 @@ import { IAgentPromptService, type PromptLaunchResult } from '#/agent/prompt/pro
import { ITelemetryService } from '#/app/telemetry/telemetry';
import { IAgentLoopService, type Turn } from '#/agent/loop/loop';
import { IWireService } from '#/wire/wire';
import { IAgentSkillService, type SkillActivationInput } from './skill';
import {
IAgentSkillService,
type PromptSkillActivation,
type PromptWithSkillsInput,
type SkillActivationInput,
} from './skill';
import { skillActivate } from './skillOps';
import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog';
import { IEventService } from '#/app/event/event';
Expand Down Expand Up @@ -106,8 +123,6 @@ export class AgentSkillService extends Service implements IAgentSkillService {
'Cannot activate skill while another turn is active',
);
}
// Awaited (not fire-and-forget): the caller gets the launched turn id and
// activation failures (unknown skill, busy) surface instead of vanishing.
if (this.scopeContext.agentId === MAIN_AGENT_ID) {
await applyPromptMetadataUpdate(
{
Expand All @@ -121,10 +136,102 @@ export class AgentSkillService extends Service implements IAgentSkillService {
return { turn_id: turn.id };
}

async promptWithSkills(input: PromptWithSkillsInput): Promise<PromptLaunchResult | undefined> {
if (input.input.length === 0) {
throw new Error2(ErrorCodes.REQUEST_INVALID, 'promptWithSkills requires a non-empty prompt');
}
if (input.skills.length === 0) {
throw new Error2(
ErrorCodes.REQUEST_INVALID,
'promptWithSkills requires at least one skill',
);
}
await this.skillCatalog.ready;
const prepared = input.skills.map((skill) => this.prepareBundled(skill));
if (this.scopeContext.agentId === MAIN_AGENT_ID) {
await applyPromptMetadataUpdate(
{
metadata: this.metadata,
eventService: this.eventService,
sessionId: this.sessionContext.sessionId,
},
promptMetadataTextFromContentParts(input.input),
);
}
for (const activation of prepared) {
void this.recordActivation(activation.origin);
}
const handle = await this.prompt.enqueue({
message: {
role: 'user',
content: [...prepared.map((activation) => activation.part), ...input.input],
toolCalls: [],
origin: {
kind: 'user',
skillActivations: prepared.map((activation) => activation.entry),
},
Comment thread
chengluyu marked this conversation as resolved.
},
});
if (handle.state === 'pending') return undefined;
const turn = await handle.launched;
return turn === undefined ? undefined : { turn_id: turn.id };
}

recordModelToolActivation(origin: SkillActivationOrigin): void {
void this.recordActivation(origin);
}

private prepareBundled(input: PromptSkillActivation): {
readonly origin: SkillActivationOrigin;
readonly part: ContentPart;
readonly entry: BundledSkillActivation;
} {
const skill = this.skillCatalog.catalog.getSkill(input.name);
if (skill === undefined) {
throw new Error2(ErrorCodes.SKILL_NOT_FOUND, `Skill "${input.name}" was not found`);
}
if (!isUserActivatableSkillType(skill.metadata.type)) {
throw new Error2(
ErrorCodes.SKILL_TYPE_UNSUPPORTED,
`Skill "${skill.name}" cannot be activated by the user`,
);
}

const skillArgs = input.args ?? '';
const skillContent = this.renderSkillPrompt(skill, skillArgs);
const origin: SkillActivationOrigin = {
kind: 'skill_activation',
activationId: randomUUID(),
skillName: skill.name,
trigger: 'user-slash',
skillType: skill.metadata.type,
skillPath: skill.path,
skillSource: skill.source,
skillArgs: input.args,
};
return {
origin,
part: {
type: 'text',
text: renderUserSlashSkillPrompt({
skillName: skill.name,
skillArgs,
skillContent,
skillSource: skill.source,
skillDir: skill.dir,
}),
},
entry: {
activationId: origin.activationId,
skillName: origin.skillName,
skillArgs: origin.skillArgs,
skillType: origin.skillType,
skillPath: origin.skillPath,
skillSource: origin.skillSource,
},
};
}

private async recordActivation(
origin: SkillActivationOrigin,
input?: readonly ContentPart[],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
* `digest` title sources: assistant segments keep only the final natural
* language text of the turn (tool calls, thinking, and media parts never
* contribute; the shared metadata sanitizer redacts secrets and long
* base64-looking runs). The window may be post-compaction — acceptable for
* base64-looking runs; rendered skill blocks bundled into a prompt's
* content are excluded, so titles reflect the caller's own text). The
* window may be post-compaction — acceptable for
* title generation: compaction keeps the head user messages, and a title
* derived from the surviving tail is a fine degradation. Bound at Agent
* scope.
Expand Down Expand Up @@ -50,7 +52,7 @@ export class AgentTitlePromptSourceService implements IAgentTitlePromptSource {
if (seenMessageIds.has(message.id)) return;
seenMessageIds.add(message.id);
}
const text = promptMetadataTextFromContentParts(message.content);
const text = promptMetadataTextFromUserMessage(message);
if (text !== undefined) result.push(text);
};

Expand All @@ -62,7 +64,7 @@ export class AgentTitlePromptSourceService implements IAgentTitlePromptSource {
const all = this.combinedMessages();
const firstUserIndex = all.findIndex(isNaturalLanguagePrompt);
if (firstUserIndex < 0) return {};
const user = promptMetadataTextFromContentParts(all[firstUserIndex]!.content);
const user = promptMetadataTextFromUserMessage(all[firstUserIndex]!);
const span: ContextMessage[] = [];
for (const message of all.slice(firstUserIndex + 1)) {
if (isNaturalLanguagePrompt(message)) break;
Expand All @@ -82,10 +84,10 @@ export class AgentTitlePromptSourceService implements IAgentTitlePromptSource {
break;
}
}
const firstUser = promptMetadataTextFromContentParts(all[firstUserIndex]!.content);
const firstUser = promptMetadataTextFromUserMessage(all[firstUserIndex]!);
const lastUser =
lastUserIndex > firstUserIndex
? promptMetadataTextFromContentParts(all[lastUserIndex]!.content)
? promptMetadataTextFromUserMessage(all[lastUserIndex]!)
: undefined;
const assistant =
finalAssistantText(all.slice(lastUserIndex + 1)) ??
Expand All @@ -108,6 +110,13 @@ function isNaturalLanguagePrompt(message: ContextMessage): boolean {
return origin === undefined || origin.kind === 'user';
}

function promptMetadataTextFromUserMessage(message: ContextMessage): string | undefined {
const bundled = message.origin?.kind === 'user' ? (message.origin.skillActivations?.length ?? 0) : 0;
return promptMetadataTextFromContentParts(
bundled === 0 ? message.content : message.content.slice(bundled),
);
}

function finalAssistantText(messages: readonly ContextMessage[]): string | undefined {
for (let index = messages.length - 1; index >= 0; index--) {
const message = messages[index]!;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,11 @@ function promptMetadataFromTurnRecord(record: WireRecord): string | undefined {
}
const content = message['content'];
if (!Array.isArray(content)) return undefined;
return promptMetadataTextFromContentParts(content as readonly ContentPart[]);
const activations = origin?.['skillActivations'];
const bundled = origin?.['kind'] === 'user' && Array.isArray(activations) ? activations.length : 0;
return promptMetadataTextFromContentParts(
(bundled === 0 ? content : content.slice(bundled)) as readonly ContentPart[],
);
}

function slashCommandText(command: string, args: unknown): string {
Expand Down
Loading
Loading