diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/index.test.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/index.test.ts index 33f819775..f707f18f9 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/index.test.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/index.test.ts @@ -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', diff --git a/apps/stage-tamagotchi/src/renderer/stores/plugin-tools.test.ts b/apps/stage-tamagotchi/src/renderer/stores/plugin-tools.test.ts index 5f878d364..a1bb37f50 100644 --- a/apps/stage-tamagotchi/src/renderer/stores/plugin-tools.test.ts +++ b/apps/stage-tamagotchi/src/renderer/stores/plugin-tools.test.ts @@ -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[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() }) /** diff --git a/apps/stage-tamagotchi/src/renderer/stores/plugin-tools.ts b/apps/stage-tamagotchi/src/renderer/stores/plugin-tools.ts index 85ed41e1d..4ebb07baa 100644 --- a/apps/stage-tamagotchi/src/renderer/stores/plugin-tools.ts +++ b/apps/stage-tamagotchi/src/renderer/stores/plugin-tools.ts @@ -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 { diff --git a/apps/stage-tamagotchi/src/shared/eventa/plugin/tools.ts b/apps/stage-tamagotchi/src/shared/eventa/plugin/tools.ts index 6be024cc3..026815956 100644 --- a/apps/stage-tamagotchi/src/shared/eventa/plugin/tools.ts +++ b/apps/stage-tamagotchi/src/shared/eventa/plugin/tools.ts @@ -41,8 +41,47 @@ export interface ElectronPluginXsaiToolDefinition { parameters: Record } +/** + * 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('eventa:invoke:electron:plugins:tools:list') -export const electronPluginListXsaiTools = defineInvokeEventa('eventa:invoke:electron:plugins:tools:list-xsai') +export const electronPluginListXsaiTools = defineInvokeEventa('eventa:invoke:electron:plugins:tools:list-xsai') export const electronPluginInvokeTool = defineInvokeEventa { }, 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(), }, }, } diff --git a/packages/plugin-sdk-tamagotchi/src/tools/index.ts b/packages/plugin-sdk-tamagotchi/src/tools/index.ts index 1275de7ef..646932459 100644 --- a/packages/plugin-sdk-tamagotchi/src/tools/index.ts +++ b/packages/plugin-sdk-tamagotchi/src/tools/index.ts @@ -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 { * - Resolves once every tool has been registered with the host */ export interface DefineToolsetOptions { + id?: string + prompt?: ToolsetPromptManifest tools: Array> } @@ -331,6 +333,13 @@ export async function defineToolset( ): Promise { 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 diff --git a/packages/plugin-sdk/src/plugin-host/core.test.ts b/packages/plugin-sdk/src/plugin-host/core.test.ts index 4e6a263ca..bbf556c08 100644 --- a/packages/plugin-sdk/src/plugin-host/core.test.ts +++ b/packages/plugin-sdk/src/plugin-host/core.test.ts @@ -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`, ) diff --git a/packages/plugin-sdk/src/plugin-host/core.ts b/packages/plugin-sdk/src/plugin-host/core.ts index 92ec41c7e..588069f22 100644 --- a/packages/plugin-sdk/src/plugin-host/core.ts +++ b/packages/plugin-sdk/src/plugin-host/core.ts @@ -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 { // Step 0 (channel gateway preparation): resolve runtime and transport for this plugin. const runtime = options.runtime ?? this.runtime diff --git a/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/tools.ts b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/tools.ts index ebd883de5..f9609ae68 100644 --- a/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/tools.ts +++ b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/tools.ts @@ -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 } +/** + * 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 +} + /** * In-memory registry for plugin-contributed tools. * @@ -39,6 +61,7 @@ export interface ToolRegistryRecord { */ export class ToolRegistryService { private readonly tools = new Map() + private readonly toolsetPrompts = new Map() 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 { 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) { diff --git a/packages/plugin-sdk/src/plugin-host/shared/tools.ts b/packages/plugin-sdk/src/plugin-host/shared/tools.ts index ad1e49cf9..d4c3b62e6 100644 --- a/packages/plugin-sdk/src/plugin-host/shared/tools.ts +++ b/packages/plugin-sdk/src/plugin-host/shared/tools.ts @@ -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 +} diff --git a/packages/plugin-sdk/src/plugin/apis/client/tools/index.ts b/packages/plugin-sdk/src/plugin/apis/client/tools/index.ts index d6a836f79..f06606cd4 100644 --- a/packages/plugin-sdk/src/plugin/apis/client/tools/index.ts +++ b/packages/plugin-sdk/src/plugin/apis/client/tools/index.ts @@ -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 } +/** + * 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 + registerToolsetPrompt: (input: RegisterToolsetPromptInput) => Promise | void } function createMissingBindingError(method: string) { @@ -93,6 +108,9 @@ export function createTools(_ctx: EventContext, 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) + }, } } diff --git a/packages/stage-ui/src/stores/llm-toolset-prompts.test.ts b/packages/stage-ui/src/stores/llm-toolset-prompts.test.ts new file mode 100644 index 000000000..603162f25 --- /dev/null +++ b/packages/stage-ui/src/stores/llm-toolset-prompts.test.ts @@ -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('') + }) +}) diff --git a/packages/stage-ui/src/stores/llm-toolset-prompts.ts b/packages/stage-ui/src/stores/llm-toolset-prompts.ts new file mode 100644 index 000000000..85182c7ae --- /dev/null +++ b/packages/stage-ui/src/stores/llm-toolset-prompts.ts @@ -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>({}) + + 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, + } +})