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
@@ -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() }) }),
+4
View File
@@ -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'>>>
+15 -3
View File
@@ -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 }>>
}