feat(plugin-*): prompt for toolset

This commit is contained in:
Neko Ayaka
2026-05-15 18:13:31 +08:00
parent 5c71f46f99
commit bc69df548e
13 changed files with 407 additions and 40 deletions
@@ -477,6 +477,14 @@ describe('for PluginHost', () => {
availability: () => false,
execute: async () => ({ ok: true, ended: true }),
})
await session.apis.tools.registerToolsetPrompt({
id: 'chess-tools',
prompt: {
id: 'airi-plugin-game-chess.prompt',
title: 'Chess Plugin Guidance',
content: 'Do not pass fen or pgn when mode is "new".',
},
})
await expect(host.listAvailableToolDescriptors()).resolves.toEqual([
{
@@ -489,21 +497,34 @@ describe('for PluginHost', () => {
},
},
])
await expect(host.listSerializedXsaiTools()).resolves.toEqual([
{
ownerPluginId: session.identity.plugin.id,
name: 'play_chess',
description: 'Open chess.',
parameters: {
type: 'object',
properties: {
opening: {
type: 'string',
await expect(host.listSerializedXsaiTools()).resolves.toEqual({
prompts: [
{
ownerPluginId: session.identity.plugin.id,
id: 'chess-tools',
prompt: {
id: 'airi-plugin-game-chess.prompt',
title: 'Chess Plugin Guidance',
content: 'Do not pass fen or pgn when mode is "new".',
},
},
],
tools: [
{
ownerPluginId: session.identity.plugin.id,
name: 'play_chess',
description: 'Open chess.',
parameters: {
type: 'object',
properties: {
opening: {
type: 'string',
},
},
},
},
},
])
],
})
await expect(host.invokeTool(session.identity.plugin.id, 'play_chess', { opening: 'sicilian' })).resolves.toEqual({
ok: true,
input: { opening: 'sicilian' },
@@ -550,7 +571,7 @@ describe('for PluginHost', () => {
host.stop(session.id)
await expect(host.listAvailableToolDescriptors()).resolves.toEqual([])
await expect(host.listSerializedXsaiTools()).resolves.toEqual([])
await expect(host.listSerializedXsaiTools()).resolves.toEqual({ prompts: [], tools: [] })
await expect(host.invokeTool(session.identity.plugin.id, 'play_chess', {})).rejects.toThrow(
`Plugin tool not found: ${session.identity.plugin.id}:play_chess`,
)
@@ -612,7 +633,7 @@ describe('for PluginHost', () => {
expect(host.getSession(session.id)).toBeUndefined()
expect(host.getBinding('module-ready-hook-failure')).toBeUndefined()
await expect(host.listAvailableToolDescriptors()).resolves.toEqual([])
await expect(host.listSerializedXsaiTools()).resolves.toEqual([])
await expect(host.listSerializedXsaiTools()).resolves.toEqual({ prompts: [], tools: [] })
await expect(host.invokeTool(session.identity.plugin.id, 'ready_hook_tool', {})).rejects.toThrow(
`Plugin tool not found: ${session.identity.plugin.id}:ready_hook_tool`,
)
@@ -668,7 +689,7 @@ describe('for PluginHost', () => {
expect(host.getSession(session.id)).toBeUndefined()
expect(host.getBinding('module-stopped-hook-failure')).toBeUndefined()
await expect(host.listAvailableToolDescriptors()).resolves.toEqual([])
await expect(host.listSerializedXsaiTools()).resolves.toEqual([])
await expect(host.listSerializedXsaiTools()).resolves.toEqual({ prompts: [], tools: [] })
await expect(host.invokeTool(session.identity.plugin.id, 'stopped_hook_tool', {})).rejects.toThrow(
`Plugin tool not found: ${session.identity.plugin.id}:stopped_hook_tool`,
)
+24 -1
View File
@@ -2,7 +2,7 @@ import type { ActorRefFrom } from 'xstate'
import type { createApis } from '../plugin/apis/client'
import type { AnnounceBindingInput, UpdateBindingInput } from '../plugin/apis/client/bindings'
import type { RegisterToolInput } from '../plugin/apis/client/tools'
import type { RegisterToolInput, RegisterToolsetPromptInput } from '../plugin/apis/client/tools'
import type { Plugin } from '../plugin/shared'
import type { BindingRecord, KitCapabilityDescriptor, KitDescriptor } from './shared'
import type {
@@ -873,6 +873,7 @@ export class PluginHost {
},
tools: {
register: input => this.registerTool(session.id, input),
registerToolsetPrompt: input => this.registerToolsetPrompt(session.id, input),
},
})
@@ -1177,6 +1178,28 @@ export class PluginHost {
})
}
registerToolsetPrompt(sessionId: string, input: RegisterToolsetPromptInput) {
const session = this.getSessionOrThrow(sessionId)
this.assertPermission(session, {
area: 'apis',
action: 'invoke',
key: pluginToolApiRegisterEventName,
})
this.assertPermission(session, {
area: 'resources',
action: 'write',
key: pluginToolRegistryResourceKey,
})
this.tools.registerToolsetPrompt({
ownerSessionId: session.id,
ownerPluginId: session.identity.plugin.id,
toolset: structuredClone(input),
availability: () => Boolean(this.getSession(session.id)),
})
}
async load(manifest: ManifestV1, options: PluginLoadOptions = {}): Promise<PluginHostSession> {
// Step 0 (channel gateway preparation): resolve runtime and transport for this plugin.
const runtime = options.runtime ?? this.runtime
@@ -1,7 +1,10 @@
import type {
PluginToolDefinitionRecord,
PluginToolsetPromptDefinitionRecord,
RegisteredPluginToolDescriptor,
SerializedToolsetPromptDefinition,
SerializedXsaiToolDefinition,
SerializedXsaiToolsetDefinition,
} from '../../../shared'
/**
@@ -24,6 +27,25 @@ export interface ToolRegistryRecord {
execute: (input: unknown) => Promise<unknown> | unknown
}
/**
* Stores one plugin toolset prompt registration inside the in-memory host runtime.
*
* Use when:
* - Tracking prompt ownership and lifecycle for a plugin-owned toolset
*
* Expects:
* - `ownerPluginId` and `toolset.id` together are unique
*
* Returns:
* - A host-managed record used for prompt serialization
*/
export interface ToolsetPromptRegistryRecord {
ownerSessionId: string
ownerPluginId: string
toolset: PluginToolsetPromptDefinitionRecord
availability?: () => Promise<boolean> | boolean
}
/**
* In-memory registry for plugin-contributed tools.
*
@@ -39,6 +61,7 @@ export interface ToolRegistryRecord {
*/
export class ToolRegistryService {
private readonly tools = new Map<string, ToolRegistryRecord>()
private readonly toolsetPrompts = new Map<string, ToolsetPromptRegistryRecord>()
register(record: ToolRegistryRecord) {
const key = `${record.ownerPluginId}:${record.tool.id}`
@@ -46,6 +69,12 @@ export class ToolRegistryService {
return record
}
registerToolsetPrompt(record: ToolsetPromptRegistryRecord) {
const key = `${record.ownerPluginId}:${record.toolset.id}`
this.toolsetPrompts.set(key, record)
return record
}
async listAvailableDescriptors() {
const items: RegisteredPluginToolDescriptor[] = []
@@ -68,7 +97,25 @@ export class ToolRegistryService {
return items
}
async listSerializedXsaiTools() {
async listToolsetPrompts() {
const prompts: SerializedToolsetPromptDefinition[] = []
for (const record of this.toolsetPrompts.values()) {
if (await record.availability?.() === false) {
continue
}
prompts.push({
ownerPluginId: record.ownerPluginId,
id: record.toolset.id,
prompt: structuredClone(record.toolset.prompt),
})
}
return prompts
}
async listSerializedXsaiTools(): Promise<SerializedXsaiToolsetDefinition> {
const items: SerializedXsaiToolDefinition[] = []
for (const record of this.tools.values()) {
@@ -84,7 +131,10 @@ export class ToolRegistryService {
})
}
return items
return {
prompts: await this.listToolsetPrompts(),
tools: items,
}
}
async invoke(ownerPluginId: string, toolId: string, input: unknown) {
@@ -43,6 +43,59 @@ export interface SerializedXsaiToolDefinition {
parameters: HostDataRecord
}
/**
* Describes model-facing guidance shared by every tool in one plugin toolset.
*
* Use when:
* - A toolset needs shared usage policy without duplicating prompt text on each tool
*
* Expects:
* - `content` is ready to append into a runtime system prompt
*
* Returns:
* - A serializable manifest that the host can pass to renderer prompt stores
*/
export interface ToolsetPromptManifest {
id: string
title?: string
content: string
}
/**
* Captures one registered toolset prompt with plugin ownership metadata.
*
* Use when:
* - Serializing plugin-contributed toolset guidance across host boundaries
*
* Expects:
* - `id` is stable within the owning plugin session
*
* Returns:
* - A prompt contribution suitable for renderer LLM prompt injection
*/
export interface SerializedToolsetPromptDefinition {
ownerPluginId: string
id: string
prompt: ToolsetPromptManifest
}
/**
* Bundles plugin xsai tools with their shared toolset prompt contributions.
*
* Use when:
* - The renderer refreshes plugin-backed tools and model prompt guidance together
*
* Expects:
* - Tools and prompts have already been filtered for active sessions
*
* Returns:
* - A serializable snapshot for renderer tool and prompt stores
*/
export interface SerializedXsaiToolsetDefinition {
tools: SerializedXsaiToolDefinition[]
prompts: SerializedToolsetPromptDefinition[]
}
/**
* Captures the single source-of-truth definition submitted by a plugin.
*
@@ -65,3 +118,20 @@ export interface PluginToolDefinitionRecord {
}
parameters: HostDataRecord
}
/**
* Captures a plugin-owned prompt shared by a toolset.
*
* Use when:
* - A plugin registers model-facing guidance for a group of related tools
*
* Expects:
* - `prompt` content is validated by the authoring helper or caller
*
* Returns:
* - A host-owned record that can be filtered by session lifecycle
*/
export interface PluginToolsetPromptDefinitionRecord {
id: string
prompt: ToolsetPromptManifest
}
@@ -1,6 +1,6 @@
import type { EventContext } from '@moeru/eventa'
import type { PluginToolDefinitionRecord } from '../../../../plugin-host/shared'
import type { PluginToolDefinitionRecord, PluginToolsetPromptDefinitionRecord } from '../../../../plugin-host/shared'
/**
* Identifies the bound API call used to register plugin tools.
@@ -48,6 +48,20 @@ export interface RegisterToolInput {
execute: (input: unknown) => Promise<unknown> | unknown
}
/**
* Carries one low-level toolset prompt registration request into the host.
*
* Use when:
* - A plugin wants shared model-facing guidance for a group of tools
*
* Expects:
* - `id` is stable within the owning plugin
*
* Returns:
* - A registration payload consumed by the bound host implementation
*/
export type RegisterToolsetPromptInput = PluginToolsetPromptDefinitionRecord
/**
* Defines the host-side callbacks needed by the low-level plugin tool client.
*
@@ -62,6 +76,7 @@ export interface RegisterToolInput {
*/
export interface ToolClientBindings {
register: (input: RegisterToolInput) => Promise<void> | void
registerToolsetPrompt: (input: RegisterToolsetPromptInput) => Promise<void> | void
}
function createMissingBindingError(method: string) {
@@ -93,6 +108,9 @@ export function createTools(_ctx: EventContext<any, any>, bindings?: ToolClientB
async register(input: RegisterToolInput) {
return await requireBinding(bindings, 'tools.register').register(input)
},
async registerToolsetPrompt(input: RegisterToolsetPromptInput) {
return await requireBinding(bindings, 'tools.registerToolsetPrompt').registerToolsetPrompt(input)
},
}
}