Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 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.
64 changes: 50 additions & 14 deletions packages/agent-core-v2/src/agent/prompt/promptService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,23 @@ interface Record extends PromptSnapshot {
handle: PromptHandle;
}

function mergeSteerMessages(records: readonly Record[]): ContextMessage {
const skillActivations = records.flatMap((item) =>
item.message.origin?.kind === 'user' ? (item.message.origin.skillActivations ?? []) : [],
);
return {
role: 'user',
content: records.flatMap((item) => item.message.content),
toolCalls: [],
origin: skillActivations.length === 0 ? USER_PROMPT_ORIGIN : { kind: 'user', skillActivations },
Comment thread
chengluyu marked this conversation as resolved.
Outdated
};
}

function stripBundledSkillBlocks(message: ContextMessage): ContentPart[] {
const bundled = message.origin?.kind === 'user' ? (message.origin.skillActivations?.length ?? 0) : 0;
return bundled === 0 ? message.content : message.content.slice(bundled);
}

export const promptLaunchingKey = defineState<boolean>('prompt.launching', () => false);

export class AgentPromptService implements IAgentPromptService {
Expand Down Expand Up @@ -284,22 +301,37 @@ export class AgentPromptService implements IAgentPromptService {
throw new Error2(ErrorCodes.PROMPT_NOT_FOUND, 'one or more prompts are not pending');
}
const selected = this.pending.filter((item) => ids.has(item.id));
for (const item of selected) this.pending.splice(this.pending.indexOf(item), 1);
const message: ContextMessage = {
role: 'user', content: selected.flatMap((item) => item.message.content), toolCalls: [], origin: USER_PROMPT_ORIGIN,
};
const { message: rerouted, captions } = this.extractCompressionCaptions(message);
const activeAtEntry = this.active;
const { message: rerouted, captions } = this.extractCompressionCaptions(mergeSteerMessages(selected));
await this.materializeDaemonRefs(rerouted);
Comment thread
chengluyu marked this conversation as resolved.
if (selected.some((item) => !this.pending.includes(item)) || this.active !== activeAtEntry) {
throw new Error2(ErrorCodes.PROMPT_NOT_FOUND, 'one or more prompts are no longer pending');
}
const removed: { readonly item: Record; readonly index: number }[] = [];
for (const item of selected) {
const index = this.pending.indexOf(item);
removed.push({ item, index });
this.pending.splice(index, 1);
Comment thread
chengluyu marked this conversation as resolved.
}
const request = new SteerStepRequest(rerouted, captions, this.reminders, (materialized) => {
void this.dispatcher.dispatch(
new TurnSteer({ input: materialized.content, origin: materialized.origin ?? USER_PROMPT_ORIGIN }),
);
}, () => {});
const turn = (await this.loop.enqueue(request).assigned).turn;
if (turn === undefined) throw new Error2(ErrorCodes.PROMPT_NOT_FOUND, 'no active turn to steer into');
let turn: Turn | undefined;
try {
turn = (await this.loop.enqueue(request).assigned).turn;
} catch {
turn = undefined;
}
if (turn === undefined || this.active !== activeAtEntry) {
for (const { item, index } of removed.reverse()) this.pending.splice(index, 0, item);
throw new Error2(ErrorCodes.PROMPT_NOT_FOUND, 'no active turn to steer into');
Comment thread
chengluyu marked this conversation as resolved.
}
for (const item of selected) { item.state = 'steered'; item.launchedDeferred.resolve(turn); }
this.steered.set(this.active.id, [...(this.steered.get(this.active.id) ?? []), ...selected]);
void this.dispatcher.dispatch(
new PromptSteered({ activePromptId: this.active.id, promptIds: selected.map((x) => x.id), content: rerouted.content as ContentPart[], steeredAt: new Date().toISOString() }),
new PromptSteered({ activePromptId: this.active.id, promptIds: selected.map((x) => x.id), content: selected.flatMap((item) => stripBundledSkillBlocks(item.message)), steeredAt: new Date().toISOString() }),
);
return selected.map((item) => item.handle);
}
Expand All @@ -322,6 +354,7 @@ export class AgentPromptService implements IAgentPromptService {

async inject(message: ContextMessage): Promise<Turn | undefined> {
const { message: rerouted, captions } = this.extractCompressionCaptions(message);
await this.materializeDaemonRefs(rerouted);
const request = new SteerStepRequest(rerouted, captions, this.reminders, (materialized) => {
void this.dispatcher.dispatch(
new TurnSteer({ input: materialized.content, origin: materialized.origin ?? USER_PROMPT_ORIGIN }),
Expand All @@ -345,11 +378,7 @@ export class AgentPromptService implements IAgentPromptService {
try {
if (this.fullCompaction.compacting !== null && this.loop.status().state !== 'running') { this.pending.unshift(item); return; }
const { message, captions } = this.extractCompressionCaptions(item.message);
if (message.content.some((part) => daemonFileRefFromPart(part) !== undefined)) {
const files = this.instantiation.invokeFunction((accessor) => accessor.get(IFileService));
const mediaStore = this.instantiation.invokeFunction((accessor) => accessor.get(ISessionMediaStore));
await materializePromptDaemonRefs(message.content, { files, mediaStore });
}
await this.materializeDaemonRefs(message);
if (await this.blockedByHook(message, false)) {
this.appendPrompt(message, captions); item.state = 'blocked'; item.launchedDeferred.resolve(undefined);
item.completionDeferred.resolve({ promptId: item.id, result: undefined, state: 'blocked' });
Expand Down Expand Up @@ -381,6 +410,13 @@ export class AgentPromptService implements IAgentPromptService {
void this.startNext();
}

private async materializeDaemonRefs(message: ContextMessage): Promise<void> {
if (!message.content.some((part) => daemonFileRefFromPart(part) !== undefined)) return;
const files = this.instantiation.invokeFunction((accessor) => accessor.get(IFileService));
const mediaStore = this.instantiation.invokeFunction((accessor) => accessor.get(ISessionMediaStore));
await materializePromptDaemonRefs(message.content, { files, mediaStore });
}

private async blockedByHook(promptMessage: ContextMessage, isSteer: boolean): Promise<boolean> {
const ctx = { promptMessage, isSteer, block: false }; await this.hooks.onBeforeSubmitPrompt.run(ctx); return ctx.block;
}
Expand Down Expand Up @@ -420,7 +456,7 @@ export class AgentPromptService implements IAgentPromptService {
private publishCompleted(promptId: string, reason: 'completed' | 'failed' | 'blocked'): void { void this.dispatcher.dispatch(new PromptCompleted({ promptId, finishedAt: new Date().toISOString(), reason })); }
private publishQueued(record: Record): void {
if ((record.message.origin ?? USER_PROMPT_ORIGIN).kind !== 'user') return;
void this.dispatcher.dispatch(new PromptQueued({ promptId: record.id, content: record.message.content, queueLength: this.pending.length }));
void this.dispatcher.dispatch(new PromptQueued({ promptId: record.id, content: stripBundledSkillBlocks(record.message), queueLength: this.pending.length }));
}
private publishAborted(promptId: string): void { void this.dispatcher.dispatch(new PromptAborted({ promptId, abortedAt: new Date().toISOString() })); }
}
Expand Down
9 changes: 8 additions & 1 deletion packages/agent-core-v2/src/agent/skill/skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,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
32 changes: 23 additions & 9 deletions packages/agent-core-v2/src/agent/skill/skillService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { ISessionContext } from '#/session/sessionContext/sessionContext';
import { Service } from '#/_base/di/service';
import { ErrorCodes, Error2 } from '#/errors';
import { isUserActivatableSkillType, type SkillDefinition } from '#/app/skillCatalog/types';
import { IAgentPromptService, type PromptLaunchResult } from '#/agent/prompt/prompt';
import { IAgentPromptService, reservePrompt, type PromptLaunchResult } from '#/agent/prompt/prompt';
import { ITelemetryService } from '#/app/telemetry/telemetry';
import { IAgentLoopService, type Turn } from '#/agent/loop/loop';
import { IAgentStateService } from '#/agent/state/agentState';
Expand All @@ -24,6 +24,7 @@ import {
IAgentSkillService,
type PromptSkillActivation,
type PromptWithSkillsInput,
type PromptWithSkillsResult,
type SkillActivationInput,
} from './skill';
import { SkillActivate, skillKey } from './skillOps';
Expand Down Expand Up @@ -114,7 +115,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 All @@ -139,20 +140,33 @@ export class AgentSkillService extends Service implements IAgentSkillService {
for (const activation of prepared) {
void this.recordActivation(activation.origin);
}
const handle = await this.prompt.enqueue({
message: {
const reservation = reservePrompt(this.prompt);
try {
const handle = await reservation.submit({
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 };
});
if (handle.state === 'pending') {
return { prompt_id: handle.id, created_at: handle.createdAt, state: 'queued' };
}
const turn = await handle.launched;
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',
};
} finally {
reservation.dispose();
}
}

recordModelToolActivation(origin: SkillActivationOrigin): void {
Expand Down
142 changes: 140 additions & 2 deletions packages/agent-core-v2/test/agent/prompt/promptService.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import { describe, expect, it, onTestFinished, vi } from 'vitest';

import { Readable } from 'node:stream';

import { DisposableStore } from '#/_base/di/lifecycle';
import { createServices } from '#/_base/di/test';
import { Event } from '#/_base/event';
import { IAgentBlobService } from '#/agent/blob/agentBlobService';
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
import type { ContextMessage } from '#/agent/contextMemory/types';
import type { ContentPart } from '#/kosong/contract/message';
import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompaction';
import { IAgentLoopService } from '#/agent/loop/loop';
import { IAgentPromptService } from '#/agent/prompt/prompt';
import { AgentPromptService, PromptQueued } from '#/agent/prompt/promptService';
import { AgentPromptService, PromptQueued, PromptSteered } from '#/agent/prompt/promptService';
import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/scopeContext';
import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder';
import { AgentSystemReminderService } from '#/agent/systemReminder/systemReminderService';
Expand All @@ -26,6 +29,8 @@ import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata';
import { IEventDispatcher } from '#/state/eventDispatcher';
import { EventDispatcherService } from '#/state/eventDispatcherService';
import { IWireService } from '#/wire/wire';
import { IFileService } from '#/app/file/fileService';
import { ISessionMediaStore } from '#/agent/media/sessionMediaStore';

import { stubContextMemory } from '../contextMemory/stubs';
import { stubLoopWithHooks, stubToolExecutor, stubWire } from '../loop/stubs';
Expand All @@ -35,6 +40,15 @@ function message(text: string): ContextMessage {
return { role: 'user', content: [{ type: 'text', text }], toolCalls: [], origin: { kind: 'user' } };
}

function bundledMessage(skillName: string, user: string, extra: readonly ContentPart[] = []): ContextMessage {
return {
role: 'user',
content: [{ type: 'text', text: `<skill>${skillName}</skill>` }, { type: 'text', text: user }, ...extra],
toolCalls: [],
origin: { kind: 'user', skillActivations: [{ activationId: `act-${skillName}`, skillName }] },
};
}

const noopBlob: IAgentBlobService = {
_serviceBrand: undefined,
offloadParts: async (parts) => parts,
Expand All @@ -54,6 +68,19 @@ function harness() {
hooks: createHooks(['onWillCompact']),
onDidFinishCompaction: Event.None,
} as unknown as IAgentFullCompactionService;
const intake = {
get: vi.fn(async () => ({
meta: {
id: 'file_1',
size: 3,
name: 'pic.png',
media_type: 'image/png',
created_at: '2026-01-01T00:00:00.000Z',
},
stream: () => Readable.from([new Uint8Array([1, 2, 3])]),
})),
materialize: vi.fn(async (): Promise<string | undefined> => undefined),
};
const ix = createServices(disposables, {
strict: true, additionalServices: (reg) => {
registerStateServices(reg);
Expand All @@ -76,9 +103,11 @@ function harness() {
reg.definePartialInstance(IEventService, { publish: () => {} });
reg.definePartialInstance(ISessionContext, { sessionId: 'test-session' });
reg.defineInstance(IAgentScopeContext, makeAgentScopeContext({ agentId: 'main', agentScope: '' }));
reg.definePartialInstance(IFileService, { get: intake.get });
reg.definePartialInstance(ISessionMediaStore, { materialize: intake.materialize });
}
});
return { prompt: ix.get(IAgentPromptService), loop, context, fullCompaction, eventBus: ix.get(IEventBus) };
return { prompt: ix.get(IAgentPromptService), loop, context, fullCompaction, eventBus: ix.get(IEventBus), intake };
}

describe('AgentPromptService', () => {
Expand Down Expand Up @@ -239,4 +268,113 @@ describe('AgentPromptService', () => {
parts.some((part) => part.type === 'text' && part.text.includes('image/avif')),
).toBe(true);
});

it('materializes daemon-ref media at steer intake', async () => {
const { prompt, intake } = harness();
const active = await prompt.enqueue({ message: message('active') });
await active.launched;
const queued = await prompt.enqueue({
id: 'prompt-steer-daemon',
message: {
role: 'user',
content: [{ type: 'image_url', imageUrl: { url: 'kimi-file://file_1' } }],
toolCalls: [],
origin: { kind: 'user' },
},
});

await prompt.steer([queued.id]);

expect(intake.get).toHaveBeenCalledWith('file_1');
expect(intake.materialize).toHaveBeenCalledWith(
expect.objectContaining({ fileId: 'file_1', name: 'pic.png' }),
);
});

it('publishes each record’s user parts when steering bundled prompts', async () => {
const { prompt, eventBus } = harness();
const steered: ContentPart[][] = [];
eventBus.subscribe(PromptSteered, (event) => steered.push(event.content));
const active = await prompt.enqueue({ message: message('active') });
await active.launched;
const one = await prompt.enqueue({ message: bundledMessage('review', 'first user text') });
const two = await prompt.enqueue({ message: bundledMessage('security', 'second user text') });

await prompt.steer([one.id, two.id]);

expect(steered).toHaveLength(1);
expect(steered[0]).toEqual([
{ type: 'text', text: 'first user text' },
{ type: 'text', text: 'second user text' },
]);
});

it('restores failed steers to their original queue positions', async () => {
const { prompt, loop } = harness();
const active = await prompt.enqueue({ message: message('active') });
await active.launched;
await prompt.enqueue({ id: 'a', message: message('a') });
await prompt.enqueue({ id: 'b', message: message('b') });
await prompt.enqueue({ id: 'c', message: message('c') });
vi.spyOn(loop, 'enqueue').mockImplementation(() => {
throw new Error('boom');
});

await expect(prompt.steer(['b'])).rejects.toMatchObject({ code: 'prompt.not_found' });

expect(prompt.list().pending.map((item) => item.id)).toEqual(['a', 'b', 'c']);
});

it('publishes only caller parts when a bundled prompt queues', async () => {
const { prompt, eventBus } = harness();
const queued: Array<{ promptId: string; content: ContentPart[] }> = [];
eventBus.subscribe(PromptQueued, (event) => {
queued.push({ promptId: event.promptId, content: event.content });
});
const active = await prompt.enqueue({ message: message('active') });
await active.launched;

await prompt.enqueue({ id: 'bundled', message: bundledMessage('review', 'user text') });

expect(queued).toEqual([
{ promptId: 'bundled', content: [{ type: 'text', text: 'user text' }] },
]);
});

it('rejects the whole steer when a selected prompt is aborted during intake', async () => {
const { prompt, intake } = harness();
const active = await prompt.enqueue({ message: message('active') });
await active.launched;
let releaseIntake!: () => void;
intake.get.mockImplementationOnce(
() =>
new Promise((resolve) => {
releaseIntake = () =>
resolve({
meta: {
id: 'file_1',
size: 3,
name: 'pic.png',
media_type: 'image/png',
created_at: '2026-01-01T00:00:00.000Z',
},
stream: () => Readable.from([new Uint8Array([1, 2, 3])]),
});
}),
);
await prompt.enqueue({
id: 'a',
message: bundledMessage('review', 'a text', [
{ type: 'image_url', imageUrl: { url: 'kimi-file://file_1' } },
]),
});
await prompt.enqueue({ id: 'b', message: message('b') });

const steerPromise = prompt.steer(['a', 'b']);
prompt.abort('a');
releaseIntake();

await expect(steerPromise).rejects.toMatchObject({ code: 'prompt.not_found' });
expect(prompt.list().pending.map((item) => item.id)).toEqual(['b']);
});
});
Loading
Loading