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:
Yichen Jiang
2026-08-08 00:51:24 +08:00
parent 62c308f415
commit 85422f44dc
13 changed files with 261 additions and 16 deletions

View File

@@ -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: {