-
Notifications
You must be signed in to change notification settings - Fork 1.1k
feat(kap-server): accept bundled skill activations on the prompt submission route #2982
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 6 commits
f76fa4a
6bce8b3
09deb90
90907f2
f3ef045
4825665
eb4e6db
e03a946
0124751
ae982a5
8305710
bbbaa60
98309f4
246a6f2
c5164a6
a27a857
95d3679
ece08d4
a0e2792
7d7c911
e4965f5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@moonshot-ai/kimi-code": patch | ||
| --- | ||
|
|
||
| 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. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,11 +14,14 @@ import { | |
| IAgentProfileService, | ||
| IAgentToolPolicyService, | ||
| IAgentPromptService, | ||
| IAgentSkillService, | ||
| IAuthSummaryService, | ||
| IEventService, | ||
| IFileService, | ||
| ISessionMediaStore, | ||
| ISessionMetadata, | ||
| ISessionSkillCatalog, | ||
| isUserActivatableSkillType, | ||
| promptMetadataTextFromContentParts, | ||
| ProfileError, | ||
| type PromptHandle, | ||
|
|
@@ -45,6 +48,7 @@ import { | |
| promptSteerResultSchema, | ||
| promptSubmissionSchema, | ||
| promptSubmitResultSchema, | ||
| type PromptSkillActivation, | ||
| } from '../protocol/rest-prompt'; | ||
| import { z } from 'zod'; | ||
|
|
||
|
|
@@ -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 | ||
|
|
@@ -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 }, | ||
|
|
@@ -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', | ||
|
chengluyu marked this conversation as resolved.
Outdated
|
||
| tags: ['prompts'], | ||
| operationId: 'submitPrompt', | ||
| }, | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the live skill catalog refreshes after this preflight—for example because a watched Useful? React with 👍 / 👎. |
||
| } | ||
| await assertPromptSessionMediaRefs( | ||
| req.body.content, | ||
| session.accessor.get(ISessionMediaStore), | ||
|
|
@@ -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, | ||
| }); | ||
|
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(); | ||
|
chengluyu marked this conversation as resolved.
Outdated
|
||
| else deferDiscardUntilPromptSettles(resolved.events, result.prompt_id, () => preparedMedia?.discard()); | ||
|
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), | ||
|
|
@@ -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 | ||
|
|
@@ -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(); | ||
|
chengluyu marked this conversation as resolved.
|
||
| } | ||
| }); | ||
| } | ||
|
|
||
|
|
||
|
|
||
| function sendMappedError( | ||
|
|
@@ -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, | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.