feat(host): user-invocable skill listing and skill.invoke injection RPC
skill.list now serves every user-invocable skill and carries modelInvocable so menus can mark user-only entries; the old model-and-user intersection hid disable-model-invocation skills from their only legitimate entry point (issue #1470). skill.invoke enforces user-invocation policy at the host boundary, renders the canonical <skill_content> body, and injects it as a user-role message carrying the skill-invocation source before starting a turn. The connection fixture mirrors both faces for client tests.
This commit is contained in:
@@ -18,6 +18,8 @@ import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
||||
import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query'
|
||||
import { SubagentError } from '@deepseek-ai/dsh-subagent'
|
||||
import type { SubagentListEntry as CatalogSubagentListEntry } from '@deepseek-ai/dsh-subagent'
|
||||
import { isSkillName, isUserInvocable, renderSkillContent } from '@deepseek-ai/dsh-skill'
|
||||
import type { SkillInvocationSource } from '@deepseek-ai/dsh-skill'
|
||||
import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
|
||||
import {
|
||||
workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId,
|
||||
@@ -2359,19 +2361,71 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
return err(request, { code: 'internal', message: 'skill registry is absent: this deployment does not mount @deepseek-ai/dsh-skill in its composition (cordis.yml or explicit assembly)', details: {} })
|
||||
}
|
||||
try {
|
||||
const skills = (await skillRegistry.list({ cwd }))
|
||||
.filter(skill => skill.invocation.modelInvocable && skill.invocation.userInvocable)
|
||||
const skills = (await skillRegistry.list({ cwd })).filter(isUserInvocable)
|
||||
return ok(request, {
|
||||
skills: skills.map(skill => ({
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
...skill.whenToUse === undefined ? {} : { whenToUse: skill.whenToUse },
|
||||
modelInvocable: skill.invocation.modelInvocable,
|
||||
})),
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
return err(request, { code: 'internal', message: `skill listing failed: ${String(error)}`, details: {} })
|
||||
}
|
||||
},
|
||||
|
||||
async invoke(request) {
|
||||
const { sessionId, name, text } = request.payload
|
||||
const found = await agentFor(sessionId)
|
||||
if ('error' in found) return err(request, found.error)
|
||||
const agent = found.agent
|
||||
// Same turn-start refusal boundary as sessions.prompt: injection
|
||||
// starts a turn, so a route no adapter serves is refused while the
|
||||
// composer still shows the draft.
|
||||
const target = targetFor(agent).current
|
||||
if (!routeServed(target.provider)) {
|
||||
return err(request, {
|
||||
code: 'model-unavailable',
|
||||
message: `no adapter serves provider "${target.provider}"; select a model for this session`,
|
||||
details: { provider: target.provider, model: target.model },
|
||||
})
|
||||
}
|
||||
const skillRegistry = ctx.get('skills')
|
||||
if (skillRegistry === undefined) {
|
||||
return err(request, { code: 'internal', message: 'skill registry is absent: this deployment does not mount @deepseek-ai/dsh-skill in its composition (cordis.yml or explicit assembly)', details: {} })
|
||||
}
|
||||
const lookup = { cwd: agent.session.header.cwd }
|
||||
// isSkillName guards the registry contract; an ill-formed name is
|
||||
// indistinguishable from an absent one for the caller.
|
||||
const summary = isSkillName(name)
|
||||
? (await skillRegistry.list(lookup)).find(skill => skill.name === name)
|
||||
: undefined
|
||||
if (summary === undefined) {
|
||||
return err(request, { code: 'skill-not-found', message: `skill "${name}" is unknown in this workspace`, details: { name } })
|
||||
}
|
||||
// The operation boundary owns user-invocation policy: client menus
|
||||
// filtering their candidates is an affordance, not enforcement.
|
||||
if (!isUserInvocable(summary)) {
|
||||
return err(request, { code: 'skill-not-invocable', message: `skill "${name}" is not available for user invocation`, details: { name } })
|
||||
}
|
||||
const skill = await skillRegistry.get(name, lookup)
|
||||
if (skill === undefined) {
|
||||
return err(request, { code: 'skill-not-found', message: `skill "${name}" is unknown in this workspace`, details: { name } })
|
||||
}
|
||||
const body = renderSkillContent(skill)
|
||||
const source: SkillInvocationSource = { kind: 'skill-invocation', name, ...text === undefined ? {} : { args: text } }
|
||||
try {
|
||||
const message: UserMessage = createUserMessage({
|
||||
content: [{ type: 'text', text: text === undefined ? body : `${body}\n\n${text}` }],
|
||||
source,
|
||||
})
|
||||
agent.followup(message)
|
||||
} catch (error: unknown) {
|
||||
return err(request, { code: 'agent-busy', message: 'skill invocation rejected', details: { reason: String(error) } })
|
||||
}
|
||||
return ok(request, { accepted: true as const })
|
||||
},
|
||||
},
|
||||
|
||||
settings: {
|
||||
|
||||
@@ -50,6 +50,7 @@ export interface RpcMethodMap {
|
||||
'command.list': CommandsApi['list']
|
||||
'command.execute': CommandsApi['execute']
|
||||
'skill.list': SkillsApi['list']
|
||||
'skill.invoke': SkillsApi['invoke']
|
||||
'goal.create': GoalsApi['create']
|
||||
'goal.edit': GoalsApi['edit']
|
||||
'goal.pause': GoalsApi['pause']
|
||||
|
||||
@@ -51,6 +51,8 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
|
||||
z.object({ code: z.literal('steer-unavailable'), message: z.string(), details: z.object({ itemId: z.string() }) }),
|
||||
z.object({ code: z.literal('command-error'), message: z.string(), details: z.object({}) }),
|
||||
z.object({ code: z.literal('unknown-command'), message: z.string(), details: z.object({}) }),
|
||||
z.object({ code: z.literal('skill-not-found'), message: z.string(), details: z.object({ name: z.string() }) }),
|
||||
z.object({ code: z.literal('skill-not-invocable'), message: z.string(), details: z.object({ name: z.string() }) }),
|
||||
z.object({ code: z.literal('settings-rejected'), message: z.string(), details: z.object({ ns: z.string() }) }),
|
||||
z.object({ code: z.literal('settings-not-exposed'), message: z.string(), details: z.object({ ns: z.string() }) }),
|
||||
z.object({ code: z.literal('settings-conflict'), message: z.string(), details: z.object({ ns: z.string(), expected: z.number(), actual: z.number() }) }),
|
||||
|
||||
@@ -51,6 +51,10 @@ export interface RpcErrorDetailsMap {
|
||||
'command-error': {}
|
||||
/** A leading-/ prompt named no registered command; the message names the token. */
|
||||
'unknown-command': {}
|
||||
/** A skill invocation named no skill in the session's workspace (unknown or ill-formed name). */
|
||||
'skill-not-found': { name: string }
|
||||
/** A skill invocation named a skill whose policy forbids user invocation. */
|
||||
'skill-not-invocable': { name: string }
|
||||
/**
|
||||
* A settings write was refused (schema validation, unknown namespace,
|
||||
* read-only provider, or storage failure); the message is the seam's text.
|
||||
|
||||
@@ -14,6 +14,7 @@ export const skillEntrySchema = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string(),
|
||||
whenToUse: z.string().optional(),
|
||||
modelInvocable: z.boolean(),
|
||||
}) satisfies z.ZodType<Wire<SkillEntry>>
|
||||
|
||||
/** skill.list request payload. */
|
||||
@@ -25,3 +26,15 @@ export const skillListRequestSchema = z.object({
|
||||
export const skillListValueSchema = z.object({
|
||||
skills: z.array(skillEntrySchema),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'skill.list'>>>
|
||||
|
||||
/** skill.invoke request payload. */
|
||||
export const skillInvokeRequestSchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
name: z.string().min(1),
|
||||
text: z.string().optional(),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'skill.invoke'>>>
|
||||
|
||||
/** skill.invoke response value. */
|
||||
export const skillInvokeValueSchema = z.object({
|
||||
accepted: z.literal(true),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'skill.invoke'>>>
|
||||
|
||||
@@ -10,16 +10,28 @@ import type { RpcRequest, RpcResponse } from './rpc.ts'
|
||||
|
||||
/** Skill catalog row (wire projection of the host SkillSummary; provider/source vocabulary stays host-side). */
|
||||
export interface SkillEntry {
|
||||
/** Kebab-case identifier referenced as `<skill>name</skill>` in prompts. */
|
||||
/** Kebab-case identifier the user references as `/name` in the composer. */
|
||||
readonly name: string
|
||||
/** Short routing description. */
|
||||
readonly description: string
|
||||
/** Optional extra routing guidance. */
|
||||
readonly whenToUse?: string
|
||||
/** False marks a user-only skill (`disable-model-invocation`): invocable here, absent from the model catalog. */
|
||||
readonly modelInvocable: boolean
|
||||
}
|
||||
|
||||
/** Skill-domain unary methods (the map key skill.* of RpcMethodMap). */
|
||||
/** Skill-domain unary methods (the map keys skill.* of RpcMethodMap). */
|
||||
export interface SkillsApi {
|
||||
/** Lists skills usable by the browser's user-selected model-reference path. */
|
||||
/** Lists the user-invocable skill catalog for the session's project. */
|
||||
list(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<{ skills: readonly SkillEntry[] }>>
|
||||
|
||||
/**
|
||||
* Injects one user-invocable skill into the addressed agent as a user-role
|
||||
* message (the canonical `<skill_content>` rendering, with `text` appended
|
||||
* when present) and starts a turn. The host enforces user-invocation policy
|
||||
* here: a model-only or unknown name is refused regardless of what a client
|
||||
* menu offered. Session-backed subagents reject with `agent-busy`.
|
||||
*/
|
||||
invoke(request: RpcRequest<{ sessionId: SessionId; name: string; text?: string }>):
|
||||
Promise<RpcResponse<{ accepted: true }>>
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
workspaceRenameValueSchema,
|
||||
} from '../api/workspace.schema.ts'
|
||||
import { commandExecuteValueSchema, commandListValueSchema } from '../api/commands.schema.ts'
|
||||
import { skillListValueSchema } from '../api/skills.schema.ts'
|
||||
import { skillInvokeValueSchema, skillListValueSchema } from '../api/skills.schema.ts'
|
||||
import {
|
||||
goalCreateValueSchema,
|
||||
goalEditValueSchema,
|
||||
@@ -118,6 +118,7 @@ export interface IApiClient {
|
||||
}
|
||||
skills: {
|
||||
list(payload: RequestPayload<'skill.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'skill.list'>>>
|
||||
invoke(payload: RequestPayload<'skill.invoke'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'skill.invoke'>>>
|
||||
}
|
||||
events: {
|
||||
mux(payload: Parameters<ApiProxy['events']['mux']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<MuxFrame>>
|
||||
@@ -185,6 +186,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
|
||||
'command.list': commandListValueSchema,
|
||||
'command.execute': commandExecuteValueSchema,
|
||||
'skill.list': skillListValueSchema,
|
||||
'skill.invoke': skillInvokeValueSchema,
|
||||
'goal.create': goalCreateValueSchema,
|
||||
'goal.edit': goalEditValueSchema,
|
||||
'goal.pause': goalPauseValueSchema,
|
||||
@@ -441,6 +443,7 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
|
||||
readonly skills: IApiClient['skills'] = {
|
||||
list: (payload, signal) => this.callUnary('skill.list', payload, signal),
|
||||
invoke: (payload, signal) => this.callUnary('skill.invoke', payload, signal),
|
||||
}
|
||||
|
||||
readonly goals: IApiClient['goals'] = {
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
workspaceRenameRequestSchema,
|
||||
} from '../api/workspace.schema.ts'
|
||||
import { commandExecuteRequestSchema, commandListRequestSchema } from '../api/commands.schema.ts'
|
||||
import { skillListRequestSchema } from '../api/skills.schema.ts'
|
||||
import { skillInvokeRequestSchema, skillListRequestSchema } from '../api/skills.schema.ts'
|
||||
import {
|
||||
goalCreateRequestSchema,
|
||||
goalEditRequestSchema,
|
||||
@@ -109,6 +109,7 @@ const UNARY_ROUTES: UnaryRoutes = {
|
||||
'command.list': { schema: commandListRequestSchema, invoke: (api, r) => api.commands.list(r) },
|
||||
'command.execute': { schema: commandExecuteRequestSchema, invoke: (api, r, signal) => api.commands.execute(r, signal) },
|
||||
'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) },
|
||||
'skill.invoke': { schema: skillInvokeRequestSchema, invoke: (api, r) => api.skills.invoke(r) },
|
||||
'goal.create': { schema: goalCreateRequestSchema, invoke: (api, r) => api.goals.create(r) },
|
||||
'goal.edit': { schema: goalEditRequestSchema, invoke: (api, r) => api.goals.edit(r) },
|
||||
'goal.pause': { schema: goalPauseRequestSchema, invoke: (api, r) => api.goals.pause(r) },
|
||||
|
||||
Reference in New Issue
Block a user