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
@@ -848,10 +848,13 @@ describe('setupPluginHost', () => {
expect.objectContaining({ id: 'play_chess' }),
expect.objectContaining({ id: 'end_play_chess' }),
])
await expect(invokeListXsaiTools()).resolves.toEqual([
expect.objectContaining({ name: 'play_chess' }),
expect.objectContaining({ name: 'end_play_chess' }),
])
await expect(invokeListXsaiTools()).resolves.toEqual({
prompts: [],
tools: [
expect.objectContaining({ name: 'play_chess' }),
expect.objectContaining({ name: 'end_play_chess' }),
],
})
await expect(invokePluginTool({
ownerPluginId: session.identity.plugin.id,
name: 'play_chess',
@@ -1,22 +1,36 @@
import type { Tool } from '@xsai/shared-chat'
import { useLlmToolsStore } from '@proj-airi/stage-ui/stores/llm-tools'
import { useLlmToolsetPromptsStore } from '@proj-airi/stage-ui/stores/llm-toolset-prompts'
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const invokeMocks = vi.hoisted(() => ({
invokePluginTool: vi.fn(async (payload: unknown) => payload),
listPluginXsaiTools: vi.fn(async () => [
{
ownerPluginId: 'plugin-chess',
name: 'play_chess',
description: 'Play a chess move.',
parameters: {
type: 'object',
properties: {},
listPluginXsaiTools: vi.fn(async () => ({
tools: [
{
ownerPluginId: 'plugin-chess',
name: 'play_chess',
description: 'Play a chess move.',
parameters: {
type: 'object',
properties: {},
},
},
},
]),
],
prompts: [
{
ownerPluginId: 'plugin-chess',
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".',
},
},
],
})),
}))
vi.mock('@proj-airi/electron-vueuse', () => ({
@@ -51,6 +65,7 @@ describe('useTamagotchiPluginToolsStore', async () => {
*/
it('loads plugin xsai tools, proxies execution, and clears them from the shared llm-tools store', async () => {
const llmToolsStore = useLlmToolsStore()
const llmToolsetPromptsStore = useLlmToolsetPromptsStore()
const store = useTamagotchiPluginToolsStore()
const toolOptions = {} as Parameters<Tool['execute']>[1]
@@ -62,6 +77,7 @@ describe('useTamagotchiPluginToolsStore', async () => {
expect(pluginTools).toEqual([
expect.objectContaining({ function: expect.objectContaining({ name: 'play_chess' }) }),
])
expect(llmToolsetPromptsStore.activeToolsetPrompt).toContain('Do not pass fen or pgn when mode is "new".')
const executionResult = await playChessTool?.execute({
move: 'e2e4',
@@ -85,6 +101,7 @@ describe('useTamagotchiPluginToolsStore', async () => {
store.dispose()
expect(llmToolsStore.toolsByProvider['plugin-tools']).toBeUndefined()
expect(llmToolsetPromptsStore.promptsByProvider['plugin-tools']).toBeUndefined()
})
/**
@@ -1,6 +1,7 @@
import { errorMessageFrom } from '@moeru/std'
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import { useLlmToolsStore } from '@proj-airi/stage-ui/stores/llm-tools'
import { useLlmToolsetPromptsStore } from '@proj-airi/stage-ui/stores/llm-toolset-prompts'
import { rawTool } from '@xsai/tool'
import { defineStore } from 'pinia'
@@ -20,6 +21,7 @@ import { electronPluginInvokeTool, electronPluginListXsaiTools } from '../../sha
*/
export const useTamagotchiPluginToolsStore = defineStore('tamagotchi-plugin-tools', () => {
const llmToolsStore = useLlmToolsStore()
const llmToolsetPromptsStore = useLlmToolsetPromptsStore()
const listPluginXsaiToolDefinitions = useElectronEventaInvoke(electronPluginListXsaiTools)
const invokePluginTool = useElectronEventaInvoke(electronPluginInvokeTool)
@@ -32,13 +34,22 @@ export const useTamagotchiPluginToolsStore = defineStore('tamagotchi-plugin-tool
listPluginXsaiToolDefinitions(undefined, { signal: abortController.signal })
.catch((error) => {
console.warn(`[plugin-tools] Failed to list plugin xsai tools: ${errorMessageFrom(error) ?? 'Unknown error'}`)
return []
return { prompts: [], tools: [] }
})
.finally(() => {
clearTimeout(timeout)
})
.then(definitions =>
definitions.map(definition =>
.then((definitions) => {
llmToolsetPromptsStore.registerToolsetPrompts(
'plugin-tools',
definitions.prompts.map(definition => ({
id: `${definition.ownerPluginId}:${definition.id}`,
title: definition.prompt.title,
content: definition.prompt.content,
})),
)
return definitions.tools.map(definition =>
rawTool({
name: definition.name,
description: definition.description,
@@ -49,13 +60,14 @@ export const useTamagotchiPluginToolsStore = defineStore('tamagotchi-plugin-tool
input,
}),
}),
),
),
)
}),
)
}
function dispose() {
llmToolsStore.clearTools('plugin-tools')
llmToolsetPromptsStore.clearToolsetPrompts('plugin-tools')
}
return {
@@ -41,8 +41,47 @@ export interface ElectronPluginXsaiToolDefinition {
parameters: Record<string, unknown>
}
/**
* Serialized toolset prompt exposed by the plugin host.
*
* Use when:
* - Registering plugin-backed prompt guidance in the renderer
*
* Expects:
* - `content` is already model-facing prompt text
*
* Returns:
* - N/A
*/
export interface ElectronPluginToolsetPromptDefinition {
ownerPluginId: string
id: string
prompt: {
id: string
title?: string
content: string
}
}
/**
* Serialized plugin xsai tools and shared prompt guidance.
*
* Use when:
* - Refreshing renderer LLM tool registrations from the Electron plugin host
*
* Expects:
* - The host filtered out inactive plugin sessions
*
* Returns:
* - N/A
*/
export interface ElectronPluginXsaiToolsetDefinition {
tools: ElectronPluginXsaiToolDefinition[]
prompts: ElectronPluginToolsetPromptDefinition[]
}
export const electronPluginListAgentTools = defineInvokeEventa<ElectronPluginToolDescriptor[]>('eventa:invoke:electron:plugins:tools:list')
export const electronPluginListXsaiTools = defineInvokeEventa<ElectronPluginXsaiToolDefinition[]>('eventa:invoke:electron:plugins:tools:list-xsai')
export const electronPluginListXsaiTools = defineInvokeEventa<ElectronPluginXsaiToolsetDefinition>('eventa:invoke:electron:plugins:tools:list-xsai')
export const electronPluginInvokeTool = defineInvokeEventa<unknown, {
ownerPluginId: string
name: string
@@ -32,6 +32,7 @@ describe('plugin-sdk-tamagotchi', () => {
},
tools: {
register: registerTool,
registerToolsetPrompt: vi.fn(),
},
kits: {
list: async () => [
@@ -75,6 +76,12 @@ describe('plugin-sdk-tamagotchi', () => {
})
await defineToolset(ctx, {
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: [
{
id: 'play_chess',
@@ -88,6 +95,14 @@ describe('plugin-sdk-tamagotchi', () => {
],
})
expect(ctx.apis.tools.registerToolsetPrompt).toHaveBeenCalledWith({
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".',
},
})
expect(gamelet).toBeDefined()
expect(registerBinding).toHaveBeenCalledWith({
moduleId: 'chess',
@@ -167,6 +182,7 @@ describe('plugin-sdk-tamagotchi', () => {
},
tools: {
register: registerTool,
registerToolsetPrompt: vi.fn(),
},
},
}
@@ -223,6 +239,7 @@ describe('plugin-sdk-tamagotchi', () => {
},
tools: {
register: registerTool,
registerToolsetPrompt: vi.fn(),
},
},
}
@@ -1,5 +1,5 @@
import type { ContextInit } from '@proj-airi/plugin-sdk'
import type { HostDataRecord } from '@proj-airi/plugin-sdk/plugin-host'
import type { HostDataRecord, ToolsetPromptManifest } from '@proj-airi/plugin-sdk/plugin-host'
import type { JsonSchema, Schema as StandardSchemaV1 } from 'xsschema'
import { hostDataRecordSchema } from '@proj-airi/plugin-sdk/plugin-host'
@@ -118,6 +118,8 @@ export interface PluginToolDefinition<TInputSchema = unknown> {
* - Resolves once every tool has been registered with the host
*/
export interface DefineToolsetOptions<TInputSchema = unknown> {
id?: string
prompt?: ToolsetPromptManifest
tools: Array<PluginToolDefinition<TInputSchema>>
}
@@ -331,6 +333,13 @@ export async function defineToolset(
): Promise<void> {
const executionContext = createToolExecutionContext(ctx)
if (options.prompt) {
await ctx.apis.tools.registerToolsetPrompt({
id: options.id ?? options.prompt.id,
prompt: options.prompt,
})
}
for (const definition of options.tools) {
const isAvailable = definition.isAvailable
@@ -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)
},
}
}
@@ -0,0 +1,35 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { useLlmToolsetPromptsStore } from './llm-toolset-prompts'
describe('useLlmToolsetPromptsStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
/**
* @example
* store.registerToolsetPrompts('plugin-tools', [{ id: 'chess', content: 'Use chess correctly.' }])
* expect(store.activeToolsetPrompt).toContain('Use chess correctly.')
*/
it('renders active toolset prompts grouped by provider and clears them by provider', () => {
const store = useLlmToolsetPromptsStore()
store.registerToolsetPrompts('plugin-tools', [
{
id: 'airi-plugin-game-chess.prompt',
title: 'Chess Plugin Guidance',
content: 'Do not pass fen or pgn when mode is "new".',
},
])
expect(store.activeToolsetPrompt).toContain('Runtime Toolset Guidance')
expect(store.activeToolsetPrompt).toContain('Chess Plugin Guidance')
expect(store.activeToolsetPrompt).toContain('Do not pass fen or pgn when mode is "new".')
store.clearToolsetPrompts('plugin-tools')
expect(store.activeToolsetPrompt).toBe('')
})
})
@@ -0,0 +1,53 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
export interface LlmToolsetPromptContribution {
id: string
title?: string
content: string
}
function renderToolsetPrompts(prompts: LlmToolsetPromptContribution[]) {
const activePrompts = prompts.filter(prompt => prompt.content.trim().length > 0)
if (activePrompts.length === 0) {
return ''
}
const lines = ['## Toolset', '']
for (const prompt of activePrompts) {
if (prompt.title) {
lines.push(`### ${prompt.title}`, '')
}
lines.push(prompt.content.trim())
lines.push('')
}
return lines.join('\n').trim()
}
export const useLlmToolsetPromptsStore = defineStore('llm-toolset-prompts', () => {
const promptsByProvider = ref<Record<string, LlmToolsetPromptContribution[]>>({})
function registerToolsetPrompts(provider: string, prompts: LlmToolsetPromptContribution[]) {
promptsByProvider.value = {
...promptsByProvider.value,
[provider]: structuredClone(prompts),
}
}
function clearToolsetPrompts(provider: string) {
const { [provider]: _removed, ...remaining } = promptsByProvider.value
promptsByProvider.value = remaining
}
const activeToolsetPrompt = computed(() => renderToolsetPrompts(Object.values(promptsByProvider.value).flat()))
return {
activeToolsetPrompt,
clearToolsetPrompts,
promptsByProvider,
registerToolsetPrompts,
}
})