mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-17 20:55:34 +00:00
feat(agent-core-v2): bundle multiple skill activations into one prompt submission (#2934)
* feat(agent-core-v2): support grouped multi-skill prompt submissions Add IAgentSkillService.promptWithSkills: one or more skill activations are validated up front (an unknown or empty submission rejects with no side effects), recorded with a shared submissionId, and enqueued ahead of the prompt through the prompt queue's messagesBefore support, so the whole group materializes atomically as a single turn. Undo cuts, the transcript projection, and the undo precheck treat the group as one unit (stopping at the next anchor even when submission ids collide); hook-result messages are skipped like injections during those walks. Submit hooks run against every message of the group, and user-slash skill activations count as user-submitted content for the UserPromptSubmit hook's origin filter. Surface it through the contract layers: protocol gains submissionId on the user / skill_activation origins and on the skill.activated event (kap-server zod mirrored), klient exposes agentSkillContract.promptWithSkills with parity assertions, and the SDK grows session.promptWithSkills — implemented on the v2 engine and rejecting loudly on the deprecated v1 engine, which is otherwise untouched. * fix(agent-core-v2): reject empty skill lists in grouped prompt submissions - Validate that promptWithSkills receives at least one skill, enforced in the engine and as a non-empty constraint in the klient wire schema. - Restore the released versions and changelog sections for agent-core-v2, klient, and node-sdk that the branch cut had reverted. - Move statement-level narration into the owning file headers per the package comment conventions. - Align the hook-result undo tests with the reachable record ordering (hook results are recorded before the group materializes). * refactor(agent-core-v2): bundle grouped skill activations into the prompt message Replace the submissionId-correlated message group with a single bundled user message: the rendered skill blocks precede the caller's parts in the content, and every activation's metadata rides the prompt origin's new skillActivations field. The bundle is one anchor by construction, so undo needs no group-cutting logic and the messagesBefore prompt seam disappears; the submit hook fires once per submission. skill.activated still fires per skill (transient ops, live-only); resume rebuilds the per-skill view from the prompt origin. Contract chain (protocol, kap-server, klient, node-sdk) drops submissionId accordingly. * fix(agent-core-v2): keep bundled skill blocks out of prompt-facing projections - The transcript cold rebuild expands a bundled prompt's origin skillActivations back into per-skill markers (the live path already projects them from skill.activated events). - turn.started.prompt, the session title excerpt source, and the fork lastPrompt now derive from the caller's own parts, excluding the rendered skill blocks the engine prepends to the bundled content. - Drop the redundant undefined unions from the new origin fields. - Move the activateSkill test narration into the file header.
This commit is contained in:
parent
84da6629b1
commit
61591bce09
28 changed files with 712 additions and 22 deletions
5
.changeset/inline-multi-skill-sdk.md
Normal file
5
.changeset/inline-multi-skill-sdk.md
Normal file
|
|
@ -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
packages/agent-core-v2/docs/state-manifest.d.ts
vendored
24
packages/agent-core-v2/docs/state-manifest.d.ts
vendored
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -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';
|
||||
|
|
@ -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';
|
||||
|
|
@ -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(
|
||||
{
|
||||
|
|
@ -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),
|
||||
},
|
||||
},
|
||||
});
|
||||
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[],
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
@ -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)) ??
|
||||
|
|
@ -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]!;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,16 @@
|
|||
* failures (unknown skill, busy agent) surface to the caller instead of
|
||||
* fire-and-forget. Run: `pnpm --filter @moonshot-ai/agent-core-v2
|
||||
* exec vitest run test/agent/skill/activateSkill.test.ts`.
|
||||
*
|
||||
* Scenario: `IAgentSkillService.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 (one text part per skill, in
|
||||
* order) and every activation's metadata rides the prompt origin's
|
||||
* `skillActivations`. The bundle launches exactly one turn (one LLM call)
|
||||
* and undoes as a single anchor; `skill.activated` fires per skill before
|
||||
* `turn.started`. Unknown skill names, an empty skill list, and an empty
|
||||
* prompt each reject the whole submission with zero side effects (no LLM
|
||||
* call, no context, no events).
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
|
@ -37,12 +47,9 @@ describe('activateSkill', () => {
|
|||
ctx.mockNextResponse({ type: 'text', text: 'committed' });
|
||||
|
||||
const launched = await ctx.rpc.activateSkill({ name: 'commit', args: '-m fix' });
|
||||
// Turn ids are 0-based; the point is the launch result came back at all.
|
||||
expect(launched?.turn_id).toBe(0);
|
||||
|
||||
await ctx.untilTurnEnd();
|
||||
// JSON.stringify escapes the block's attribute quotes — assert on the
|
||||
// quote-free fragments.
|
||||
const llmInput = JSON.stringify(ctx.llmInputs());
|
||||
expect(llmInput).toContain('skill-loaded');
|
||||
expect(llmInput).toContain('# Commit body');
|
||||
|
|
@ -55,3 +62,140 @@ describe('activateSkill', () => {
|
|||
await expect(ctx.rpc.activateSkill({ name: 'missing' })).rejects.toThrow(/not found/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('promptWithSkills', () => {
|
||||
let ctx: TestAgentContext;
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
await ctx.expectResumeMatches();
|
||||
} finally {
|
||||
await ctx.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
function agentWithSkills(): TestAgentContext {
|
||||
const catalog = new InMemorySkillCatalog();
|
||||
catalog.register(stubSkill('review', { content: '# Review body' }));
|
||||
catalog.register(stubSkill('security', { content: '# Security body' }));
|
||||
return createTestAgent(skillServices(catalog));
|
||||
}
|
||||
|
||||
it('bundles every skill into the prompt message and launches exactly one turn', async () => {
|
||||
ctx = agentWithSkills();
|
||||
ctx.mockNextResponse({ type: 'text', text: 'done' });
|
||||
|
||||
const launched = await ctx.rpc.promptWithSkills({
|
||||
input: [{ type: 'text', text: 'Review this change.' }],
|
||||
skills: [{ name: 'review' }, { name: 'security' }],
|
||||
});
|
||||
expect(launched?.turn_id).toBe(0);
|
||||
await ctx.untilTurnEnd();
|
||||
|
||||
expect(ctx.llmCalls).toHaveLength(1);
|
||||
const llmInput = JSON.stringify(ctx.llmInputs());
|
||||
expect(llmInput).toContain('# Review body');
|
||||
expect(llmInput).toContain('# Security body');
|
||||
expect(llmInput).toContain('Review this change.');
|
||||
|
||||
const messages = ctx.context.get();
|
||||
const promptMessage = messages.find((message) => message.origin?.kind === 'user');
|
||||
expect(messages.filter((message) => message.origin?.kind === 'skill_activation')).toHaveLength(
|
||||
0,
|
||||
);
|
||||
expect(promptMessage?.origin).toMatchObject({
|
||||
kind: 'user',
|
||||
skillActivations: [{ skillName: 'review' }, { skillName: 'security' }],
|
||||
});
|
||||
const texts = promptMessage?.content
|
||||
.filter((part) => part.type === 'text')
|
||||
.map((part) => part.text);
|
||||
expect(texts?.[0]).toContain('# Review body');
|
||||
expect(texts?.[1]).toContain('# Security body');
|
||||
expect(texts?.[2]).toContain('Review this change.');
|
||||
|
||||
const events = ctx.allEvents.filter(
|
||||
(event) =>
|
||||
event.type === '[rpc]' &&
|
||||
(event.event === 'skill.activated' || event.event === 'turn.started'),
|
||||
);
|
||||
expect(events.map((event) => event.event)).toEqual([
|
||||
'skill.activated',
|
||||
'skill.activated',
|
||||
'turn.started',
|
||||
]);
|
||||
expect(
|
||||
events
|
||||
.slice(0, 2)
|
||||
.map((event) => (event.args as { readonly skillName?: string }).skillName),
|
||||
).toEqual(['review', 'security']);
|
||||
const started = events[2]?.args as { readonly prompt?: string };
|
||||
expect(started.prompt).toBe('Review this change.');
|
||||
});
|
||||
|
||||
it('rejects the whole submission when any skill is unknown', async () => {
|
||||
ctx = agentWithSkills();
|
||||
|
||||
await expect(
|
||||
ctx.rpc.promptWithSkills({
|
||||
input: [{ type: 'text', text: 'Review this change.' }],
|
||||
skills: [{ name: 'review' }, { name: 'missing' }],
|
||||
}),
|
||||
).rejects.toThrow(/not found/i);
|
||||
|
||||
expect(ctx.llmCalls).toHaveLength(0);
|
||||
expect(ctx.context.get()).toHaveLength(0);
|
||||
expect(
|
||||
ctx.allEvents.some((event) => event.type === '[rpc]' && event.event === 'skill.activated'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a grouped submission with an empty prompt message', async () => {
|
||||
ctx = agentWithSkills();
|
||||
|
||||
await expect(
|
||||
ctx.rpc.promptWithSkills({
|
||||
input: [],
|
||||
skills: [{ name: 'review' }],
|
||||
}),
|
||||
).rejects.toThrow(/non-empty prompt/i);
|
||||
|
||||
expect(ctx.llmCalls).toHaveLength(0);
|
||||
expect(ctx.context.get()).toHaveLength(0);
|
||||
expect(
|
||||
ctx.allEvents.some((event) => event.type === '[rpc]' && event.event === 'skill.activated'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a grouped submission without any skills', async () => {
|
||||
ctx = agentWithSkills();
|
||||
|
||||
await expect(
|
||||
ctx.rpc.promptWithSkills({
|
||||
input: [{ type: 'text', text: 'Review this change.' }],
|
||||
skills: [],
|
||||
}),
|
||||
).rejects.toThrow(/at least one skill/i);
|
||||
|
||||
expect(ctx.llmCalls).toHaveLength(0);
|
||||
expect(ctx.context.get()).toHaveLength(0);
|
||||
expect(
|
||||
ctx.allEvents.some((event) => event.type === '[rpc]' && event.event === 'skill.activated'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('undoes the bundled prompt as a single anchor', async () => {
|
||||
ctx = agentWithSkills();
|
||||
ctx.mockNextResponse({ type: 'text', text: 'done' });
|
||||
await ctx.rpc.promptWithSkills({
|
||||
input: [{ type: 'text', text: 'Review this change.' }],
|
||||
skills: [{ name: 'review' }, { name: 'security' }],
|
||||
});
|
||||
await ctx.untilTurnEnd();
|
||||
expect(ctx.context.get().length).toBeGreaterThan(0);
|
||||
|
||||
const undone = await ctx.rpc.undoHistory({ count: 1 });
|
||||
expect(undone).toBe(1);
|
||||
expect(ctx.context.get()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -234,6 +234,7 @@ describe('SkillTool', () => {
|
|||
return {
|
||||
_serviceBrand: undefined,
|
||||
activate: () => Promise.reject(new Error('not implemented')),
|
||||
promptWithSkills: () => Promise.reject(new Error('not implemented')),
|
||||
recordModelToolActivation: () => {},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ interface StopTaskPayload { readonly taskId: string; readonly reason?: string }
|
|||
interface UndoHistoryPayload { readonly count: number }
|
||||
interface UnregisterToolPayload { readonly name: string }
|
||||
import { type UsageStatus } from '#/agent/usage/usage';
|
||||
import { IAgentSkillService, type SkillActivationInput } from '#/agent/skill/skill';
|
||||
import { IAgentSkillService, type PromptWithSkillsInput, type SkillActivationInput } from '#/agent/skill/skill';
|
||||
import { AgentSkillService } from '#/agent/skill/skillService';
|
||||
import { IAgentRuntimeBindingSeed } from '#/agent/runtimeBinding/runtimeBinding';
|
||||
import { IAgentRuntimeService } from '#/agent/runtimeBinding/agentRuntime';
|
||||
|
|
@ -335,6 +335,7 @@ type RpcPromise<T> = Promise<T> & {
|
|||
|
||||
interface AgentRpcPassthroughAPI {
|
||||
prompt: (payload: PromptPayload) => Promisable<PromptLaunchResult | undefined>;
|
||||
promptWithSkills: (payload: PromptWithSkillsInput) => Promisable<PromptLaunchResult | undefined>;
|
||||
steer: (payload: SteerPayload) => Promisable<PromptLaunchResult | undefined>;
|
||||
cancel: (payload: CancelPayload) => void;
|
||||
undoHistory: (payload: UndoHistoryPayload) => Promisable<number>;
|
||||
|
|
@ -2095,6 +2096,7 @@ export class AgentTestContext {
|
|||
private createRpcPassthroughAdapters(): AgentRpcPassthroughAPI {
|
||||
return {
|
||||
prompt: (payload) => this.get(IAgentPromptService).submit(payload),
|
||||
promptWithSkills: (payload) => this.get(IAgentSkillService).promptWithSkills(payload),
|
||||
steer: (payload) => this.get(IAgentPromptService).submitSteer(payload),
|
||||
cancel: (payload) => this.get(IAgentLoopService).cancelFromUser(payload.turnId),
|
||||
undoHistory: (payload) => this.get(IAgentConversationUndoService).undo(payload.count),
|
||||
|
|
|
|||
|
|
@ -91,4 +91,31 @@ describe('title excerpts over the real context memory', () => {
|
|||
assistant: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('excludes bundled skill blocks from the excerpt of a bundled prompt', async () => {
|
||||
const context = ctx.get(IAgentContextMemoryService);
|
||||
context.append({
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'User activated the skill "review". Follow the loaded skill instructions.' },
|
||||
{ type: 'text', text: 'User activated the skill "security". Follow the loaded skill instructions.' },
|
||||
{ type: 'text', text: '检查这次改动的正确性' },
|
||||
],
|
||||
toolCalls: [],
|
||||
origin: {
|
||||
kind: 'user',
|
||||
skillActivations: [
|
||||
{ activationId: 'act-1', skillName: 'review' },
|
||||
{ activationId: 'act-2', skillName: 'security' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const source = ctx.get(IAgentTitlePromptSource);
|
||||
await expect(source.firstTurnExcerpt()).resolves.toEqual({
|
||||
user: '检查这次改动的正确性',
|
||||
assistant: undefined,
|
||||
});
|
||||
await expect(source.firstUserPrompts(5)).resolves.toEqual(['检查这次改动的正确性']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { z } from 'zod';
|
|||
import { isoDateTimeSchema } from '@moonshot-ai/agent-core-v2/_base/utils/isoDateTime';
|
||||
import type { TurnEndReason } from '@moonshot-ai/agent-core-v2/agent/loop/turnEvents';
|
||||
import type {
|
||||
BundledSkillActivation,
|
||||
CompactionSummaryOrigin,
|
||||
CronJobOrigin,
|
||||
CronMissedOrigin,
|
||||
|
|
@ -119,8 +120,18 @@ export const permissionModeSchema = z.enum(['manual', 'yolo', 'auto']) satisfies
|
|||
|
||||
export const skillSourceSchema = z.enum(['project', 'user', 'extra', 'builtin']) satisfies z.ZodType<SkillSource>;
|
||||
|
||||
export const bundledSkillActivationSchema = z.object({
|
||||
activationId: z.string(),
|
||||
skillName: z.string(),
|
||||
skillArgs: z.string().optional(),
|
||||
skillType: z.string().optional(),
|
||||
skillPath: z.string().optional(),
|
||||
skillSource: skillSourceSchema.optional(),
|
||||
}) satisfies z.ZodType<BundledSkillActivation>;
|
||||
|
||||
export const userPromptOriginSchema = z.object({
|
||||
kind: z.literal('user'),
|
||||
skillActivations: z.array(bundledSkillActivationSchema).optional(),
|
||||
}) satisfies z.ZodType<UserPromptOrigin>;
|
||||
|
||||
export const skillActivationOriginSchema = z.object({
|
||||
|
|
|
|||
|
|
@ -41,6 +41,17 @@ export const promptPayloadSchema = z.object({
|
|||
input: z.array(promptPartSchema),
|
||||
});
|
||||
|
||||
/** Same shape as `PromptSkillActivation` in the engine. */
|
||||
export const promptSkillActivationSchema = z.object({
|
||||
name: z.string(),
|
||||
args: z.string().optional(),
|
||||
});
|
||||
|
||||
/** Same shape as `PromptWithSkillsInput` in the engine. */
|
||||
export const promptWithSkillsPayloadSchema = promptPayloadSchema.extend({
|
||||
skills: z.array(promptSkillActivationSchema).min(1),
|
||||
});
|
||||
|
||||
/** Same shape as `SteerPayload` in the engine. */
|
||||
export const steerPayloadSchema = z.object({
|
||||
input: z.array(promptPartSchema),
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
planDataSchema,
|
||||
promptLaunchResultSchema,
|
||||
promptPayloadSchema,
|
||||
promptWithSkillsPayloadSchema,
|
||||
runShellCommandPayloadSchema,
|
||||
runtimeBindingSchema,
|
||||
setModelResultSchema,
|
||||
|
|
@ -39,6 +40,10 @@ export const agentPromptContract = {
|
|||
|
||||
export const agentSkillContract = {
|
||||
activate: { input: z.tuple([activateSkillPayloadSchema]), output: promptLaunchResultSchema },
|
||||
promptWithSkills: {
|
||||
input: z.tuple([promptWithSkillsPayloadSchema]),
|
||||
output: maybe(promptLaunchResultSchema),
|
||||
},
|
||||
} satisfies ServiceContract;
|
||||
|
||||
export const agentLoopContract = {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import type { IAgentTokenCountingService } from '@moonshot-ai/agent-core-v2/agen
|
|||
import type { IAgentPlanService } from '@moonshot-ai/agent-core-v2/features/plan/plan';
|
||||
import type { IAgentProfileService } from '@moonshot-ai/agent-core-v2/agent/profile/profile';
|
||||
import type { IAgentShellCommandService } from '@moonshot-ai/agent-core-v2/agent/shellCommand/shellCommand';
|
||||
import type { IAgentSkillService } from '@moonshot-ai/agent-core-v2/agent/skill/skill';
|
||||
import type { IAgentTaskService } from '@moonshot-ai/agent-core-v2/agent/task/task';
|
||||
import type { IAgentUsageService } from '@moonshot-ai/agent-core-v2/agent/usage/usage';
|
||||
import type { ContentPart } from '@moonshot-ai/agent-core-v2/kosong/contract/message';
|
||||
|
|
@ -28,6 +29,7 @@ import type { ScopedCaller } from './session.js';
|
|||
// Wire-type aliases derived through the engine service interfaces (keeps
|
||||
// klient free of protocol-package imports).
|
||||
export type PromptLaunchResult = Awaited<ReturnType<IAgentPromptService['submit']>>;
|
||||
export type PromptWithSkillsInput = Parameters<IAgentSkillService['promptWithSkills']>[0];
|
||||
export type ShellCommandResult = Awaited<ReturnType<IAgentShellCommandService['run']>>;
|
||||
export type SetModelResult = Awaited<ReturnType<IAgentProfileService['setModel']>>;
|
||||
export type ThinkingLevel = ReturnType<IAgentProfileService['getEffectiveThinkingLevel']>;
|
||||
|
|
@ -44,6 +46,15 @@ export type McpServerEntry = ReturnType<IAgentMcpService['list']>[number];
|
|||
|
||||
export interface AgentFacade {
|
||||
prompt(input: { input: readonly ContentPart[] }): Promise<PromptLaunchResult>;
|
||||
/**
|
||||
* Submit one prompt with one or more skill activations bundled into the
|
||||
* same user message: the skills are validated up front (an unknown name or
|
||||
* an empty list rejects the whole submission), rendered ahead of the
|
||||
* caller's parts in the same turn, and the bundle undoes as a single
|
||||
* anchor. Resolves with the launched turn id, or `undefined` when the
|
||||
* submission queued behind a running turn.
|
||||
*/
|
||||
promptWithSkills(input: PromptWithSkillsInput): Promise<PromptLaunchResult>;
|
||||
steer(input: { input: readonly ContentPart[] }): Promise<PromptLaunchResult>;
|
||||
/**
|
||||
* Activate a skill as a user-slash activation: the engine renders the skill
|
||||
|
|
@ -91,6 +102,8 @@ export function createAgentFacade(call: ScopedCaller, scope: ScopeRef): AgentFac
|
|||
return {
|
||||
prompt: (input) =>
|
||||
call(scope, 'agentPromptService', 'submit', [input]) as Promise<PromptLaunchResult>,
|
||||
promptWithSkills: (input) =>
|
||||
call(scope, 'agentSkillService', 'promptWithSkills', [input]) as Promise<PromptLaunchResult>,
|
||||
steer: (input) =>
|
||||
call(scope, 'agentPromptService', 'submitSteer', [input]) as Promise<PromptLaunchResult>,
|
||||
activateSkill: (input) =>
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ export type {
|
|||
McpServerEntry,
|
||||
PlanData,
|
||||
PromptLaunchResult,
|
||||
PromptWithSkillsInput,
|
||||
SetModelResult,
|
||||
ShellCommandResult,
|
||||
ThinkingLevel,
|
||||
|
|
|
|||
|
|
@ -164,6 +164,8 @@ import {
|
|||
promptLaunchResultSchema,
|
||||
promptPartSchema,
|
||||
promptPayloadSchema,
|
||||
promptSkillActivationSchema,
|
||||
promptWithSkillsPayloadSchema,
|
||||
runCommandPayloadSchema,
|
||||
runShellCommandPayloadSchema,
|
||||
runtimeBindingSchema,
|
||||
|
|
@ -533,6 +535,8 @@ type PromptPayload = Parameters<IAgentPromptService['submit']>[0];
|
|||
type PromptLaunchResult = NonNullable<Awaited<ReturnType<IAgentPromptService['submit']>>>;
|
||||
type SteerPayload = Parameters<IAgentPromptService['submitSteer']>[0];
|
||||
type ActivateSkillPayload = Parameters<IAgentSkillService['activate']>[0];
|
||||
type PromptWithSkillsPayload = Parameters<IAgentSkillService['promptWithSkills']>[0];
|
||||
type PromptSkillActivation = PromptWithSkillsPayload['skills'][number];
|
||||
type AgentCommandInfo = ReturnType<IAgentCommandService['list']>[number];
|
||||
type RuntimeBinding = ReturnType<IAgentRuntimeBindingService['get']>;
|
||||
type RunShellCommandPayload = Parameters<IAgentShellCommandService['run']>[0];
|
||||
|
|
@ -558,6 +562,16 @@ const _promptPart: AssertWire<typeof promptPartSchema, PromptPart> = true;
|
|||
// the full `ContentPart` union (also think/audio parts); the wire mirrors the
|
||||
// `PromptPart` subset clients may send, so the reverse direction fails.
|
||||
const _promptPayload: AssertWireToEngine<typeof promptPayloadSchema, PromptPayload> = true;
|
||||
const _promptSkillActivation: AssertWire<
|
||||
typeof promptSkillActivationSchema,
|
||||
PromptSkillActivation
|
||||
> = true;
|
||||
// Same one-directional rule as `promptPayload`: the engine's `input` accepts
|
||||
// the full `ContentPart` union; the wire mirrors the `PromptPart` subset.
|
||||
const _promptWithSkillsPayload: AssertWireToEngine<
|
||||
typeof promptWithSkillsPayloadSchema,
|
||||
PromptWithSkillsPayload
|
||||
> = true;
|
||||
const _steerPayload: AssertWireToEngine<typeof steerPayloadSchema, SteerPayload> = true;
|
||||
const _activateSkillPayload: AssertWire<typeof activateSkillPayloadSchema, ActivateSkillPayload> =
|
||||
true;
|
||||
|
|
|
|||
|
|
@ -183,6 +183,33 @@ describe('agent profile routing', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('agent skill routing', () => {
|
||||
it('promptWithSkills routes to agentSkillService.promptWithSkills with the agent scope', async () => {
|
||||
const channel = new FakeChannel();
|
||||
const klient = createKlientFromChannel(channel);
|
||||
const agent = klient.session('s1').agent('main');
|
||||
|
||||
channel.result = { turn_id: 7 };
|
||||
await expect(
|
||||
agent.promptWithSkills({
|
||||
input: [{ type: 'text', text: 'Review this change.' }],
|
||||
skills: [{ name: 'review' }, { name: 'security', args: 'src/app.ts' }],
|
||||
}),
|
||||
).resolves.toEqual({ turn_id: 7 });
|
||||
expect(channel.calls[0]).toEqual({
|
||||
scope: { sessionId: 's1', agentId: 'main' },
|
||||
service: 'agentSkillService',
|
||||
method: 'promptWithSkills',
|
||||
args: [
|
||||
{
|
||||
input: [{ type: 'text', text: 'Review this change.' }],
|
||||
skills: [{ name: 'review' }, { name: 'security', args: 'src/app.ts' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('session skills routing', () => {
|
||||
it('skills.list routes to sessionSkillCatalog.list with the session scope', async () => {
|
||||
const channel = new FakeChannel();
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ import type {
|
|||
SessionStatus,
|
||||
SessionUsage,
|
||||
PromptInput,
|
||||
PromptSkillActivation,
|
||||
RenameSessionInput,
|
||||
ResumeSessionInput,
|
||||
ResumedSessionSummary,
|
||||
|
|
@ -75,6 +76,10 @@ export interface SessionPromptRpcInput {
|
|||
readonly input: PromptInput;
|
||||
}
|
||||
|
||||
export interface SessionPromptWithSkillsRpcInput extends SessionPromptRpcInput {
|
||||
readonly skills: readonly PromptSkillActivation[];
|
||||
}
|
||||
|
||||
export interface SessionIdRpcInput {
|
||||
readonly sessionId: string;
|
||||
}
|
||||
|
|
@ -401,6 +406,19 @@ export abstract class SDKRpcClientBase {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Grouped skill activation + prompt submission. Only the v2 engine
|
||||
* (`SDKRpcClientV2`) implements it; the v1 route has no combined-submission
|
||||
* RPC, so the base fails loudly instead of degrading into N+1 turns.
|
||||
*/
|
||||
async promptWithSkills(input: SessionPromptWithSkillsRpcInput): Promise<void> {
|
||||
void input;
|
||||
throw new KimiError(
|
||||
ErrorCodes.NOT_IMPLEMENTED,
|
||||
'promptWithSkills requires the agent-core-v2 engine.',
|
||||
);
|
||||
}
|
||||
|
||||
async runShellCommand(input: {
|
||||
sessionId: string;
|
||||
command: string;
|
||||
|
|
|
|||
|
|
@ -241,6 +241,7 @@ import {
|
|||
type SessionIdRpcInput,
|
||||
type SwitchSessionRuntimeRpcInput,
|
||||
type SessionPromptRpcInput,
|
||||
type SessionPromptWithSkillsRpcInput,
|
||||
type SetSessionModelRpcInput,
|
||||
type SetSessionModelRpcResult,
|
||||
type SetSessionPermissionRpcInput,
|
||||
|
|
@ -1819,6 +1820,21 @@ export class SDKRpcClientV2 extends SDKRpcClientBase {
|
|||
await agent.prompt({ input: input.input });
|
||||
}
|
||||
|
||||
/**
|
||||
* Facade (`agentSkillService.promptWithSkills`) — bundled skill submission:
|
||||
* the engine renders every skill activation into the prompt's own user
|
||||
* message, so the bundle launches as one turn and undoes as a single
|
||||
* anchor. v2-only: the base class rejects this method on the v1 engine.
|
||||
* The launch result is dropped like `prompt` (v1's RPC shape returns void).
|
||||
*/
|
||||
override async promptWithSkills(input: SessionPromptWithSkillsRpcInput): Promise<void> {
|
||||
const agent = await this.agentFacade(input.sessionId);
|
||||
await agent.promptWithSkills({
|
||||
input: input.input,
|
||||
skills: input.skills,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Facade (`agentPromptService.submitSteer`). Matches v1 on both paths: mid-turn
|
||||
* steers join the running turn, and an idle-session steer degrades to
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import type {
|
|||
PluginInfo,
|
||||
PluginSummary,
|
||||
PromptInput,
|
||||
PromptSkillActivation,
|
||||
ReloadSessionOptions,
|
||||
ReloadSummary,
|
||||
ResumedSessionState,
|
||||
|
|
@ -143,6 +144,25 @@ export class Session {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit one prompt with one or more skill activations bundled into the
|
||||
* same user message: the skills are validated up front (an unknown name
|
||||
* rejects the whole submission), rendered ahead of the prompt in the same
|
||||
* turn, and the bundle undoes as a single anchor. Requires the
|
||||
* agent-core-v2 engine.
|
||||
*/
|
||||
async promptWithSkills(
|
||||
input: string | PromptInput,
|
||||
skills: readonly PromptSkillActivation[],
|
||||
): Promise<void> {
|
||||
this.ensureOpen();
|
||||
await this.rpc.promptWithSkills({
|
||||
sessionId: this.id,
|
||||
input: normalizePromptInput(input),
|
||||
skills,
|
||||
});
|
||||
}
|
||||
|
||||
/** Execute a user-initiated `!` shell command (silent — does not prompt the
|
||||
* model). Resolves with the command's stdout/stderr for immediate display.
|
||||
* Pass `commandId` to receive live `shell.output` events for this command. */
|
||||
|
|
|
|||
|
|
@ -116,6 +116,11 @@ export type PromptPart = Extract<ContentPart, { type: 'text' | 'image_url' | 'vi
|
|||
|
||||
export type PromptInput = readonly PromptPart[];
|
||||
|
||||
export interface PromptSkillActivation {
|
||||
readonly name: string;
|
||||
readonly args?: string;
|
||||
}
|
||||
|
||||
export interface KimiHarnessOptions {
|
||||
readonly identity?: KimiHostIdentity | undefined;
|
||||
readonly homeDir?: string | undefined;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from 'v
|
|||
|
||||
import {
|
||||
createKimiHarness,
|
||||
createKimiHarnessV2,
|
||||
type Event,
|
||||
type KimiError,
|
||||
type SkillActivatedEvent,
|
||||
|
|
@ -69,6 +70,25 @@ const { Session } = await import('#/index');
|
|||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
const CONFIG_ENV_PATTERN =
|
||||
/^(KIMI_MODEL_|KIMI_LOOP_|KIMI_MCP_|KIMI_WEB_|KIMI_IMAGE_|KIMI_CODE_BACKGROUND_|KIMI_CODE_MODEL_CATALOG_)/;
|
||||
|
||||
/** Keep ambient env from injecting providers/models into the v2 engine. */
|
||||
function scrubConfigEnv(): () => void {
|
||||
const saved: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (value !== undefined && CONFIG_ENV_PATTERN.test(key)) {
|
||||
saved[key] = value;
|
||||
delete process.env[key];
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
for (const [key, value] of Object.entries(saved)) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fakeProviderState.histories.length = 0;
|
||||
fakeProviderState.responseText = 'skill response';
|
||||
|
|
@ -80,6 +100,74 @@ afterEach(async () => {
|
|||
});
|
||||
|
||||
describe('Session skills', () => {
|
||||
it('submits multiple skills with a prompt as one grouped turn (v2 engine)', async () => {
|
||||
const restoreEnv = scrubConfigEnv();
|
||||
const homeDir = await makeTempDir(tempDirs, 'kimi-sdk-skills-home-');
|
||||
const workDir = await makeTempDir(tempDirs, 'kimi-sdk-skills-work-');
|
||||
await writeSkill(workDir, 'review', [
|
||||
'---',
|
||||
'name: review',
|
||||
'description: Review code',
|
||||
'---',
|
||||
'',
|
||||
'Review the requested file.',
|
||||
]);
|
||||
await writeSkill(workDir, 'security', [
|
||||
'---',
|
||||
'name: security',
|
||||
'description: Check security',
|
||||
'---',
|
||||
'',
|
||||
'Check the requested file for security issues.',
|
||||
]);
|
||||
const harness = createKimiHarnessV2({ homeDir, identity: TEST_IDENTITY });
|
||||
|
||||
try {
|
||||
const session = await harness.createSession({ id: 'ses_sdk_multi_skill', workDir });
|
||||
const events: Event[] = [];
|
||||
const unsubscribe = session.onEvent((event) => {
|
||||
events.push(event);
|
||||
});
|
||||
// Model-less on purpose: the grouped surface (activation events, single
|
||||
// turn) settles before the provider-less turn fails asynchronously.
|
||||
const ended = waitForSDKEvent(session, (event) => event.type === 'turn.ended');
|
||||
|
||||
await session.promptWithSkills(
|
||||
'Review this change.',
|
||||
[{ name: 'review' }, { name: 'security' }],
|
||||
);
|
||||
await ended;
|
||||
unsubscribe();
|
||||
|
||||
const activations = events.filter(
|
||||
(event): event is Extract<Event, { type: 'skill.activated' }> =>
|
||||
event.type === 'skill.activated',
|
||||
);
|
||||
expect(activations.map((event) => event.skillName)).toEqual(['review', 'security']);
|
||||
expect(events.filter((event) => event.type === 'turn.started')).toHaveLength(1);
|
||||
} finally {
|
||||
await harness.close();
|
||||
restoreEnv();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects promptWithSkills on the v1 engine', async () => {
|
||||
const homeDir = await makeTempDir(tempDirs, 'kimi-sdk-skills-home-');
|
||||
const workDir = await makeTempDir(tempDirs, 'kimi-sdk-skills-work-');
|
||||
const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY });
|
||||
|
||||
try {
|
||||
const session = await harness.createSession({ id: 'ses_sdk_multi_skill_v1', workDir });
|
||||
await expect(
|
||||
session.promptWithSkills('Review this change.', [{ name: 'review' }]),
|
||||
).rejects.toMatchObject({
|
||||
code: 'not_implemented',
|
||||
});
|
||||
} finally {
|
||||
await harness.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('lists session skills without exposing content', async () => {
|
||||
const homeDir = await makeTempDir(tempDirs, 'kimi-sdk-skills-home-');
|
||||
const workDir = await makeTempDir(tempDirs, 'kimi-sdk-skills-work-');
|
||||
|
|
|
|||
|
|
@ -45,6 +45,22 @@ export type SkillSource = 'project' | 'user' | 'extra' | 'builtin';
|
|||
|
||||
export interface UserPromptOrigin {
|
||||
readonly kind: 'user';
|
||||
/**
|
||||
* Skill activations bundled into this prompt: the rendered skill blocks
|
||||
* precede the caller's parts in the message content, and every activation
|
||||
* is listed here so resume / replay can rebuild the per-skill view from
|
||||
* the single bundled message.
|
||||
*/
|
||||
readonly skillActivations?: readonly BundledSkillActivation[];
|
||||
}
|
||||
|
||||
export interface BundledSkillActivation {
|
||||
readonly activationId: string;
|
||||
readonly skillName: string;
|
||||
readonly skillArgs?: string;
|
||||
readonly skillType?: string;
|
||||
readonly skillPath?: string;
|
||||
readonly skillSource?: SkillSource;
|
||||
}
|
||||
|
||||
export interface SkillActivationOrigin {
|
||||
|
|
@ -1042,8 +1058,18 @@ export const permissionModeSchema = z.enum(['manual', 'yolo', 'auto']) satisfies
|
|||
|
||||
export const skillSourceSchema = z.enum(['project', 'user', 'extra', 'builtin']) satisfies z.ZodType<SkillSource>;
|
||||
|
||||
export const bundledSkillActivationSchema = z.object({
|
||||
activationId: z.string(),
|
||||
skillName: z.string(),
|
||||
skillArgs: z.string().optional(),
|
||||
skillType: z.string().optional(),
|
||||
skillPath: z.string().optional(),
|
||||
skillSource: skillSourceSchema.optional(),
|
||||
}) satisfies z.ZodType<BundledSkillActivation>;
|
||||
|
||||
export const userPromptOriginSchema = z.object({
|
||||
kind: z.literal('user'),
|
||||
skillActivations: z.array(bundledSkillActivationSchema).optional(),
|
||||
}) satisfies z.ZodType<UserPromptOrigin>;
|
||||
|
||||
export const skillActivationOriginSchema = z.object({
|
||||
|
|
|
|||
|
|
@ -200,6 +200,25 @@ export function groupMessagesIntoSnapshot(
|
|||
}
|
||||
continue;
|
||||
}
|
||||
const bundled = bundledSkillActivations(message);
|
||||
if (bundled.length > 0) {
|
||||
// The v2 engine bundles a prompt's inline skill activations into the
|
||||
// prompt message itself: one rendered text part per skill precedes
|
||||
// the caller's parts in the content, and the origin carries every
|
||||
// activation. Expand the persisted bundle back into per-skill markers
|
||||
// so a cold rebuild shows the same cards the live events produced.
|
||||
const parts = message.content ?? [];
|
||||
bundled.forEach((activation, index) => {
|
||||
const block = parts[index];
|
||||
pushMarker('skill', {
|
||||
text: block !== undefined && block.type === 'text' && 'text' in block ? block.text : '',
|
||||
origin: { kind: 'skill_activation', trigger: 'user-slash', ...activation },
|
||||
});
|
||||
});
|
||||
const callerMessage = { ...message, content: parts.slice(bundled.length) };
|
||||
startTurn(mapOrigin(message), textOf(callerMessage), collectAttachments(callerMessage));
|
||||
continue;
|
||||
}
|
||||
startTurn(mapOrigin(message), textOf(message), collectAttachments(message));
|
||||
continue;
|
||||
}
|
||||
|
|
@ -322,6 +341,28 @@ function mapOrigin(message: HistoryMessage): TurnOrigin {
|
|||
}
|
||||
}
|
||||
|
||||
interface BundledSkillActivation {
|
||||
readonly activationId: string;
|
||||
readonly skillName: string;
|
||||
readonly skillArgs?: string;
|
||||
readonly skillType?: string;
|
||||
readonly skillPath?: string;
|
||||
readonly skillSource?: string;
|
||||
}
|
||||
|
||||
function bundledSkillActivations(message: HistoryMessage): readonly BundledSkillActivation[] {
|
||||
if (message.origin?.kind !== 'user') return [];
|
||||
const activations = (message.origin as { readonly skillActivations?: unknown }).skillActivations;
|
||||
if (!Array.isArray(activations)) return [];
|
||||
return activations.filter(
|
||||
(activation): activation is BundledSkillActivation =>
|
||||
typeof activation === 'object' &&
|
||||
activation !== null &&
|
||||
typeof (activation as { activationId?: unknown }).activationId === 'string' &&
|
||||
typeof (activation as { skillName?: unknown }).skillName === 'string',
|
||||
);
|
||||
}
|
||||
|
||||
function textOf(message: HistoryMessage): string {
|
||||
return (message.content ?? [])
|
||||
.filter((part): part is { readonly type: 'text'; readonly text: string } => part.type === 'text' && 'text' in part)
|
||||
|
|
|
|||
|
|
@ -448,6 +448,45 @@ describe('groupMessagesIntoSnapshot (cold path)', () => {
|
|||
expect(marker?.kind === 'marker' && marker.marker).toBe('compaction');
|
||||
});
|
||||
|
||||
it('expands a bundled prompt into per-skill markers and a caller-text turn', () => {
|
||||
const snapshot = groupMessagesIntoSnapshot([
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'rendered review block' },
|
||||
{ type: 'text', text: 'rendered security block' },
|
||||
{ type: 'text', text: 'please /skill:review and /skill:security' },
|
||||
],
|
||||
toolCalls: [],
|
||||
origin: {
|
||||
kind: 'user',
|
||||
skillActivations: [
|
||||
{ activationId: 'act-1', skillName: 'review' },
|
||||
{ activationId: 'act-2', skillName: 'security', skillArgs: 'src/app.ts' },
|
||||
],
|
||||
} as { kind: string },
|
||||
},
|
||||
{ role: 'assistant', content: [{ type: 'text', text: 'done' }], toolCalls: [] },
|
||||
]);
|
||||
|
||||
expect(snapshot.items.map((item) => item.kind)).toEqual(['marker', 'marker', 'turn']);
|
||||
const first = snapshot.items[0];
|
||||
expect(first?.kind === 'marker' && first.marker).toBe('skill');
|
||||
expect(first?.kind === 'marker' && first.payload).toMatchObject({
|
||||
text: 'rendered review block',
|
||||
origin: { kind: 'skill_activation', trigger: 'user-slash', skillName: 'review' },
|
||||
});
|
||||
const second = snapshot.items[1];
|
||||
expect(second?.kind === 'marker' && second.payload).toMatchObject({
|
||||
text: 'rendered security block',
|
||||
origin: { skillName: 'security', skillArgs: 'src/app.ts' },
|
||||
});
|
||||
const turn = snapshot.items[2];
|
||||
if (turn?.kind !== 'turn') throw new Error('expected turn');
|
||||
expect(turn.prompt).toBe('please /skill:review and /skill:security');
|
||||
expect(turn.steps).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('maps media parts on the opening user message to attachment entities, dropping base64 bytes', () => {
|
||||
const snapshot = groupMessagesIntoSnapshot([
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue