Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
f76fa4a
feat(kap-server): accept bundled skill activations on the prompt subm…
chengluyu Aug 17, 2026
6bce8b3
refactor(agent-core-v2): slim the promptWithSkills result contract
chengluyu Aug 17, 2026
09deb90
fix(kap-server): harden bundled skill submissions against review find…
chengluyu Aug 17, 2026
90907f2
fix(kap-server): preflight bundled skills before agent materializatio…
chengluyu Aug 17, 2026
f3ef045
Merge branch 'main' into feat/kap-prompt-with-skills
chengluyu Aug 17, 2026
4825665
fix(kap-server): reject bundled prompt_id combos at preflight and cle…
chengluyu Aug 17, 2026
eb4e6db
fix(kap-server): clean queued bundle staging on the steer path too
chengluyu Aug 17, 2026
e03a946
fix(agent-core-v2): materialize daemon-ref media on the steer and inj…
chengluyu Aug 17, 2026
0124751
fix(kap-server): defer staging cleanup to turn settlement, never to s…
chengluyu Aug 17, 2026
ae982a5
fix(kap-server): install settlement tracking before bundled enqueue
chengluyu Aug 17, 2026
8305710
fix(kap-server): scope settlement tracking to the owning agent and di…
chengluyu Aug 17, 2026
bbbaa60
fix(agent-core-v2): keep steered prompts queued until their media int…
chengluyu Aug 17, 2026
98309f4
fix(agent-core-v2): revalidate the queue and active turn after steer …
chengluyu Aug 17, 2026
246a6f2
Merge branch 'main' into feat/kap-prompt-with-skills
chengluyu Aug 17, 2026
c5164a6
fix(agent-core-v2): steer only the surviving records and keep their m…
chengluyu Aug 17, 2026
a27a857
fix(agent-core-v2): harden steer rollback and register bundled prompt…
chengluyu Aug 18, 2026
95d3679
fix(agent-core-v2): strip bundled blocks from prompt.queued and rejec…
chengluyu Aug 18, 2026
ece08d4
fix(kap-server): update session metadata for bundled prompts routed t…
chengluyu Aug 18, 2026
a0e2792
fix(agent-core-v2): restart queue after raced steer rollback and pref…
chengluyu Aug 18, 2026
7d7c911
fix(agent-core-v2): block queue advancement during steer admission
chengluyu Aug 18, 2026
e4965f5
chore: drop the changeset for server-only protocol plumbing
chengluyu Aug 18, 2026
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/kap-prompt-skills.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
Comment thread
chengluyu marked this conversation as resolved.
Outdated
---

The session prompt submission API now accepts an optional `skills` field: one or more named skills activate together with the prompt as a single bundled turn (one undo unit), validated up front with zero side effects on rejection.
13 changes: 11 additions & 2 deletions packages/agent-core-v2/src/agent/skill/skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
* 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.
* turn (`recordModelToolActivation`). `promptWithSkills` resolves with the
* queue identity of the submitted bundle (`prompt_id` / `created_at`, the
* launch `state`, and `turn_id` once launched). Bound at Agent scope.
*/

import { createDecorator } from "#/_base/di/instantiation";
Expand All @@ -34,11 +36,18 @@ export interface PromptWithSkillsInput {
readonly skills: readonly PromptSkillActivation[];
}

export interface PromptWithSkillsResult {
readonly turn_id?: number;
readonly prompt_id: string;
readonly created_at: string;
readonly state: 'running' | 'queued' | 'blocked';
}

export interface IAgentSkillService {
readonly _serviceBrand: undefined;

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

Expand Down
17 changes: 14 additions & 3 deletions packages/agent-core-v2/src/agent/skill/skillService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
IAgentSkillService,
type PromptSkillActivation,
type PromptWithSkillsInput,
type PromptWithSkillsResult,
type SkillActivationInput,
} from './skill';
import { SkillActivate, skillKey } from './skillOps';
Expand Down Expand Up @@ -130,7 +131,7 @@ export class AgentSkillService extends Service implements IAgentSkillService {
return { turn_id: turn.id };
}

async promptWithSkills(input: PromptWithSkillsInput): Promise<PromptLaunchResult | undefined> {
async promptWithSkills(input: PromptWithSkillsInput): Promise<PromptWithSkillsResult> {
if (input.input.length === 0) {
throw new Error2(ErrorCodes.REQUEST_INVALID, 'promptWithSkills requires a non-empty prompt');
}
Expand Down Expand Up @@ -166,9 +167,19 @@ export class AgentSkillService extends Service implements IAgentSkillService {
},
},
});
if (handle.state === 'pending') return undefined;
if (handle.state === 'pending') {
return { prompt_id: handle.id, created_at: handle.createdAt, state: 'queued' };
Comment thread
chengluyu marked this conversation as resolved.
Outdated
}
const turn = await handle.launched;
return turn === undefined ? undefined : { turn_id: turn.id };
if (turn === undefined && handle.state !== 'blocked') {
throw new Error2(ErrorCodes.INTERNAL, 'promptWithSkills failed to launch a turn');
}
return {
turn_id: turn?.id,
prompt_id: handle.id,
created_at: handle.createdAt,
state: handle.state === 'blocked' ? 'blocked' : 'running',
Comment thread
chengluyu marked this conversation as resolved.
Outdated
};
}

recordModelToolActivation(origin: SkillActivationOrigin): void {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,9 @@ describe('promptWithSkills', () => {
input: [{ type: 'text', text: 'Review this change.' }],
skills: [{ name: 'review' }, { name: 'security' }],
});
expect(launched?.turn_id).toBe(0);
expect(launched.turn_id).toBe(0);
expect(launched.prompt_id).toBeTruthy();
expect(launched.state).toBe('running');
await ctx.untilTurnEnd();

expect(ctx.llmCalls).toHaveLength(1);
Expand Down
4 changes: 2 additions & 2 deletions packages/agent-core-v2/test/harness/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 PromptWithSkillsInput, type SkillActivationInput } from '#/agent/skill/skill';
import { IAgentSkillService, type PromptWithSkillsInput, type PromptWithSkillsResult, type SkillActivationInput } from '#/agent/skill/skill';
import { AgentSkillService } from '#/agent/skill/skillService';
import { IAgentRuntimeBindingSeed } from '#/agent/runtimeBinding/runtimeBinding';
import { IAgentRuntimeService } from '#/agent/runtimeBinding/agentRuntime';
Expand Down Expand Up @@ -341,7 +341,7 @@ type RpcPromise<T> = Promise<T> & {

interface AgentRpcPassthroughAPI {
prompt: (payload: PromptPayload) => Promisable<PromptLaunchResult | undefined>;
promptWithSkills: (payload: PromptWithSkillsInput) => Promisable<PromptLaunchResult | undefined>;
promptWithSkills: (payload: PromptWithSkillsInput) => Promisable<PromptWithSkillsResult>;
steer: (payload: SteerPayload) => Promisable<PromptLaunchResult | undefined>;
cancel: (payload: CancelPayload) => void;
undoHistory: (payload: UndoHistoryPayload) => Promisable<number>;
Expand Down
18 changes: 17 additions & 1 deletion packages/kap-server/src/protocol/rest-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,16 @@
* POST /v1/sessions/{sid}/prompts
* Body: PromptSubmission { content, metadata?, agent_id?, profile?, model?, thinking?,
* permission_mode?, plan_mode?, swarm_mode?, goal_objective?, goal_control?,
* disabled_tools?, prompt_id? }
* disabled_tools?, prompt_id?, skills? }
* Reply: PromptSubmitResult { prompt_id, user_message_id, status, content, created_at }
*
* `skills` (optional, non-empty) submits a bundled skill prompt through
* `IAgentSkillService.promptWithSkills`: every named skill is validated up
* front (an unknown name rejects the whole submission), one `skill.activated`
* event fires per skill, and the prompt enqueues as a single user message
* with the rendered skill blocks preceding the caller's content — one turn,
* one undo unit. Each entry is `{ name, args? }` (activation by name only).
*
* GET /v1/sessions/{sid}/prompts
* Reply: { active: PromptItem | null, queued: PromptItem[] }
*
Expand All @@ -29,6 +36,12 @@ import {
export { promptPermissionModeSchema, promptThinkingSchema };
export type { PromptPermissionMode, PromptThinking } from '@moonshot-ai/agent-core-v2/app/sessionLegacy/sessionProtocol';

export const promptSkillActivationSchema = z.object({
name: z.string().min(1),
args: z.string().optional(),
});
export type PromptSkillActivation = z.infer<typeof promptSkillActivationSchema>;

export const promptSubmissionSchema = z.object({
content: z.array(messageContentSchema).min(1),
metadata: z.record(z.string(), z.unknown()).optional(),
Expand All @@ -51,6 +64,9 @@ export const promptSubmissionSchema = z.object({
// turn's `turn.started` (`promptId`) so the submitter can bind its own
// bookkeeping to that turn exactly. Omit to let the engine assign one.
prompt_id: z.string().min(1).optional(),
// Bundled skill submission: every named skill activates with the prompt as
// a single bundled user message (one turn, one undo unit).
skills: z.array(promptSkillActivationSchema).min(1).optional(),
Comment thread
chengluyu marked this conversation as resolved.
});
export type PromptSubmission = z.infer<typeof promptSubmissionSchema>;

Expand Down
127 changes: 124 additions & 3 deletions packages/kap-server/src/routes/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,14 @@ import {
IAgentProfileService,
IAgentToolPolicyService,
IAgentPromptService,
IAgentSkillService,
IAuthSummaryService,
IEventService,
IFileService,
ISessionMediaStore,
ISessionMetadata,
ISessionSkillCatalog,
isUserActivatableSkillType,
promptMetadataTextFromContentParts,
ProfileError,
type PromptHandle,
Expand All @@ -45,6 +48,7 @@ import {
promptSteerResultSchema,
promptSubmissionSchema,
promptSubmitResultSchema,
type PromptSkillActivation,
} from '../protocol/rest-prompt';
import { z } from 'zod';

Expand Down Expand Up @@ -118,13 +122,42 @@ async function resolvePromptFromSession(session: ISessionScopeHandle, agentId?:
}
return {
prompt: agent.accessor.get(IAgentPromptService),
skill: agent.accessor.get(IAgentSkillService),
events: agent.accessor.get(IEventService),
auth: agent.accessor.get(IAuthSummaryService),
profile: agent.accessor.get(IAgentProfileService),
toolPolicy: agent.accessor.get(IAgentToolPolicyService),
permissionMode: agent.accessor.get(IAgentPermissionModeService),
};
}

/**
* Read-only pre-flight for bundled skill submissions: every named skill must
* exist in the session catalog and be user-activatable. Runs before any media
* materialization or control override, so a rejected bundle leaves the
* session untouched. The engine re-validates authoritatively inside
* `promptWithSkills`; this edge check only exists to protect side effects
* that precede it (media copies, persistent overrides).
*/
async function assertActivatableSkills(
catalog: ISessionSkillCatalog,
skills: readonly PromptSkillActivation[],
): Promise<void> {
await catalog.ready;
for (const skill of skills) {
const definition = catalog.catalog.getSkill(skill.name);
if (definition === undefined) {
throw new Error2(ErrorCodes.SKILL_NOT_FOUND, `Skill "${skill.name}" was not found`);
}
if (!isUserActivatableSkillType(definition.metadata.type)) {
throw new Error2(
ErrorCodes.SKILL_TYPE_UNSUPPORTED,
`Skill "${definition.name}" cannot be activated by the user`,
);
}
}
}

/**
* Bind the resolved agent to the profile named by a prompt submission's
* `profile` field. First-bind semantics live in the engine: a same-name
Expand Down Expand Up @@ -198,6 +231,8 @@ export function registerPromptsRoutes(app: PromptRouteHost, core: Scope): void {
success: { data: promptSubmitResultSchema },
errors: {
[ErrorCode.VALIDATION_FAILED]: { detailsSchema: validationDetailsSchema },
[ErrorCode.SKILL_NOT_FOUND]: {},
[ErrorCode.SKILL_NOT_ACTIVATABLE]: {},
[ErrorCode.AUTH_PROVISIONING_REQUIRED]: {},
[ErrorCode.AUTH_TOKEN_MISSING]: { detailsSchema: authProviderDetailsSchema },
[ErrorCode.AUTH_TOKEN_UNAUTHORIZED]: { detailsSchema: authProviderDetailsSchema },
Expand All @@ -206,7 +241,7 @@ export function registerPromptsRoutes(app: PromptRouteHost, core: Scope): void {
[ErrorCode.PROMPT_ID_CONFLICT]: {},
[ErrorCode.PROMPT_ALREADY_COMPLETED]: { dataSchema: z.object({ aborted: z.literal(false) }) },
},
description: 'Submit a prompt to a session',
description: 'Submit a prompt to a session, optionally with bundled skill activations',
Comment thread
chengluyu marked this conversation as resolved.
Outdated
tags: ['prompts'],
operationId: 'submitPrompt',
},
Expand All @@ -220,7 +255,25 @@ export function registerPromptsRoutes(app: PromptRouteHost, core: Scope): void {
// mutated: a bad `file_id` must not create the agent, register `main`
// in session metadata, or touch the session's controls.
await assertPromptFileRefs(req.body.content, core.accessor.get(IFileService));
// A cold resume loads the session but does not create the main agent;
// bundled-skill preflight runs at this point precisely so a rejected
// bundle cannot even mutate session metadata by registering `main`.
const session = await resolveSession(core, session_id);
if (req.body.skills !== undefined) {
// A bundled submission goes through the engine's own enqueue path,
// which assigns the prompt id — reject the combination here, before
// the agent is materialized or any override binds.
if (req.body.prompt_id !== undefined) {
throw new Error2(
ErrorCodes.REQUEST_INVALID,
'prompt_id cannot be combined with a bundled skill submission',
);
}
await assertActivatableSkills(
session.accessor.get(ISessionSkillCatalog),
req.body.skills,
);
Comment on lines +225 to +228

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make bundled validation atomic with applying overrides

When the live skill catalog refreshes after this preflight—for example because a watched SKILL.md is deleted or changes to a non-user-activatable type—the route proceeds to persist model, profile, permission, and tool overrides before promptWithSkills revalidates the new catalog and rejects the request. That breaks the advertised zero-side-effect rejection guarantee and can leave a failed request in yolo mode; validation and the side effects/enqueue need to use one stable catalog snapshot or be coordinated by the engine.

Useful? React with 👍 / 👎.

}
await assertPromptSessionMediaRefs(
req.body.content,
session.accessor.get(ISessionMediaStore),
Expand Down Expand Up @@ -285,6 +338,38 @@ export function registerPromptsRoutes(app: PromptRouteHost, core: Scope): void {
}
}
const parts = contentToCoreParts(resolvedContent);
if (req.body.skills !== undefined) {
// Bundled skill submission: the engine validates every skill up
// front, records one activation event per skill, and enqueues a
// single user message (rendered skill blocks first, then the
// caller's parts). It owns the prompt-metadata update for the main
// agent, so this edge skips its own to avoid a double write.
const result = await resolved.skill.promptWithSkills({
input: parts,
skills: req.body.skills,
});
Comment thread
chengluyu marked this conversation as resolved.
Outdated
enqueued = true;
// Queued bundles complete media intake only when their turn pops;
// the result shape carries no handle, so the plain path's deferred
// cleanup is mirrored through the prompt lifecycle events instead.
if (result.state !== 'queued') await preparedMedia?.discard();
Comment thread
chengluyu marked this conversation as resolved.
Outdated
else deferDiscardUntilPromptSettles(resolved.events, result.prompt_id, () => preparedMedia?.discard());
Comment thread
chengluyu marked this conversation as resolved.
Outdated
reply.send(
okEnvelope(
{
prompt_id: result.prompt_id,
// prompt_id IS the user_message_id — one identity for prompt
// and message, same as the plain-prompt path.
user_message_id: result.prompt_id,
status: result.state,
content: projectPromptContentParts(parts),
created_at: result.created_at,
},
req.id,
),
);
return;
}
await applyPromptMetadataUpdate({
metadata: session.accessor.get(ISessionMetadata),
eventService: core.accessor.get(IEventService),
Expand Down Expand Up @@ -402,10 +487,16 @@ function projectPromptHandle(handle: PromptHandle) {
return projectPromptSnapshot(handle);
}

function projectPromptSnapshot(prompt: PromptQueueSnapshot['pending'][number]) {
export function projectPromptSnapshot(prompt: PromptQueueSnapshot['pending'][number]) {
const status = prompt.state === 'running' || prompt.state === 'steered'
? 'running'
: prompt.state === 'blocked' ? 'blocked' : 'queued';
const origin = prompt.message.origin;
// A bundled prompt stores the rendered skill blocks ahead of the caller's
// parts; project the caller's parts only, so the listed content matches the
// submit response for the same prompt.
const bundled = origin?.kind === 'user' ? (origin.skillActivations?.length ?? 0) : 0;
const content = bundled === 0 ? prompt.message.content : prompt.message.content.slice(bundled);
// The prompt queue holds user prompts only; the shared projection maps each
// self-contained daemon-ref media part to its `{kind:'session_media'}` wire
// shape, mirroring the message projection: the internal URL never reaches
Expand All @@ -414,11 +505,35 @@ function projectPromptSnapshot(prompt: PromptQueueSnapshot['pending'][number]) {
prompt_id: prompt.id,
user_message_id: prompt.userMessageId,
status,
content: projectPromptContentParts(prompt.message.content),
content: projectPromptContentParts(content),
created_at: prompt.createdAt,
};
}

/**
* Deferred media-staging cleanup for a queued bundled submission: the
* bundle's prompt intake only runs when its turn pops (or settles), so the
* staging upload is discarded on the matching `prompt.completed` /
* `prompt.aborted` lifecycle event rather than eagerly at submit time.
* Mirrors the plain path's `launched`/`completion`-raced discard, which the
* `promptWithSkills` result shape cannot express.
*/
export function deferDiscardUntilPromptSettles(
events: IEventService,
promptId: string,
discard: () => void | Promise<void>,
): void {
const subscription = events.subscribe((event) => {
if (
(event.type === 'prompt.completed' || event.type === 'prompt.aborted') &&
(event as { readonly promptId?: unknown }).promptId === promptId
) {
subscription.dispose();
void discard();
Comment thread
chengluyu marked this conversation as resolved.
}
});
}



function sendMappedError(
Expand Down Expand Up @@ -459,6 +574,12 @@ function sendMappedError(
case 'validation.failed':
reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, err.message, requestId, err.stack));
return;
case 'skill.not_found':
reply.send(errEnvelope(ErrorCode.SKILL_NOT_FOUND, err.message, requestId, err.stack));
return;
case 'skill.type_unsupported':
reply.send(errEnvelope(ErrorCode.SKILL_NOT_ACTIVATABLE, err.message, requestId, err.stack));
return;
case 'auth.provisioning_required':
reply.send({
code: ErrorCode.AUTH_PROVISIONING_REQUIRED,
Expand Down
Loading
Loading