diff --git a/packages/stage-ui/src/stores/character-orchestrator.test.ts b/packages/stage-ui/src/stores/character-orchestrator.test.ts index b9992b72e..7859e9c76 100644 --- a/packages/stage-ui/src/stores/character-orchestrator.test.ts +++ b/packages/stage-ui/src/stores/character-orchestrator.test.ts @@ -7,17 +7,17 @@ import type { Mock } from 'vitest' import type { UnwrapRef } from 'vue' import type z from 'zod' -import type { sparkCommandSchema } from './character-orchestrator' import type { StreamEvent } from './llm' import type { AiriCard } from './modules' import { createTestingPinia } from '@pinia/testing' +import { tool } from '@xsai/tool' import { nanoid } from 'nanoid' import { setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' import { useCharacterStore } from './character' -import { useCharacterOrchestratorStore } from './character-orchestrator' +import { sparkCommandSchema, useCharacterOrchestratorStore } from './character-orchestrator' import { useLLM } from './llm' import { useAiriCardStore, useConsciousnessStore } from './modules' import { useProvidersStore } from './providers' @@ -53,6 +53,54 @@ function mockedStore unknown>( return useStore() as any } +function getObjectSchema(schema?: Record) { + if (!schema) + return undefined + + if (schema.type === 'object') + return schema + + const candidates = [...(schema.anyOf ?? []), ...(schema.oneOf ?? [])] + return candidates.find((candidate: Record) => candidate?.type === 'object') +} + +function getArraySchema(schema?: Record) { + if (!schema) + return undefined + + if (schema.type === 'array') + return schema + + const candidates = [...(schema.anyOf ?? []), ...(schema.oneOf ?? [])] + return candidates.find((candidate: Record) => candidate?.type === 'array') +} + +describe('sparkCommandSchema', () => { + it('emits strict objects in the json schema', async () => { + const sparkTool = await tool({ + name: 'builtIn_sparkCommand', + description: 'test', + parameters: sparkCommandSchema, + execute: async () => undefined, + }) + + const schema = sparkTool.function.parameters as Record + const commandsSchema = getArraySchema(schema.properties?.commands) + const commandItemSchema = getObjectSchema(commandsSchema?.items) + const guidanceSchema = getObjectSchema(commandItemSchema?.properties?.guidance) + const personaSchema = getArraySchema(guidanceSchema?.properties?.persona) + const personaItemSchema = getObjectSchema(personaSchema?.items) + const optionsSchema = getArraySchema(guidanceSchema?.properties?.options) + const optionsItemSchema = getObjectSchema(optionsSchema?.items) + + expect(schema.additionalProperties).toBe(false) + expect(commandItemSchema?.additionalProperties).toBe(false) + expect(guidanceSchema?.additionalProperties).toBe(false) + expect(personaItemSchema?.additionalProperties).toBe(false) + expect(optionsItemSchema?.additionalProperties).toBe(false) + }) +}) + describe('store character-orchestrator', () => { beforeEach(() => { const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false }) diff --git a/packages/stage-ui/src/stores/character-orchestrator.ts b/packages/stage-ui/src/stores/character-orchestrator.ts index f2824a045..a06ee3c53 100644 --- a/packages/stage-ui/src/stores/character-orchestrator.ts +++ b/packages/stage-ui/src/stores/character-orchestrator.ts @@ -55,7 +55,7 @@ export const sparkCommandSchema = z.object({ persona: z.array(z.object({ strength: z.enum(['very-high', 'high', 'medium', 'low', 'very-low']), traits: z.string().describe('Trait name to adjust behavior. For example, "bravery", "cautiousness", "friendliness".'), - })).nullable().describe('Personas can be used to adjust the behavior of sub-agents. For example, when using as NPC in games, or player in Minecraft, the persona can help define the character\'s traits and decision-making style.'), + }).strict()).nullable().describe('Personas can be used to adjust the behavior of sub-agents. For example, when using as NPC in games, or player in Minecraft, the persona can help define the character\'s traits and decision-making style.'), options: z.array(z.object({ label: z.string().describe('Short and brief label for this option, used for identification, should be within a sentence.'), steps: z.array(z.string()).describe('Step-by-step instructions for the sub-agent to follow, useful when providing detailed guidance.'), @@ -65,10 +65,10 @@ export const sparkCommandSchema = z.object({ fallback: z.array(z.string()).nullable().describe('Fallback steps if the main steps cannot be completed.'), // TODO: consider to remove or enrich how triggers should work later triggers: z.array(z.string()).nullable().describe('Conditions or events that would trigger this option.'), - })), - }).nullable().describe('Guidance for the sub-agent on how to interpret and execute the command with given context, persona settings, and reasoning.'), - })).describe('List of commands to issue to sub-agents, you may produce multiple commands in response to multiple sub-agents by specifying their IDs in destination field. Empty array can be used for zero commands.'), -}) + }).strict()), + }).strict().nullable().describe('Guidance for the sub-agent on how to interpret and execute the command with given context, persona settings, and reasoning.'), + }).strict()).describe('List of commands to issue to sub-agents, you may produce multiple commands in response to multiple sub-agents by specifying their IDs in destination field. Empty array can be used for zero commands.'), +}).strict() export type SparkCommandSchema = z.infer @@ -97,7 +97,7 @@ export const useCharacterOrchestratorStore = defineStore('character-orchestrator const sparkNoResponseTool = await tool({ name: 'builtIn_sparkNoResponse', description: `Indicate that no response or action is needed for the current spark:notify event.`, - parameters: z.object({}), + parameters: z.object({}).strict(), execute: async (_payload) => { noResponse = true return 'AIRI System: Acknowledged, no response or action will be processed.' diff --git a/packages/stage-ui/src/tools/mcp.test.ts b/packages/stage-ui/src/tools/mcp.test.ts new file mode 100644 index 000000000..e1731c498 --- /dev/null +++ b/packages/stage-ui/src/tools/mcp.test.ts @@ -0,0 +1,33 @@ +import type { JsonSchema } from 'xsschema' + +import { describe, expect, it } from 'vitest' + +import { mcp } from './mcp' + +describe('tools mcp schema', () => { + it('emits strict parameter objects', async () => { + const tools = await mcp() + const toolNames = [ + 'mcp_list_tools', + 'mcp_connect_server', + 'mcp_disconnect_server', + 'mcp_call_tool', + ] + + for (const name of toolNames) { + const tool = tools.find(entry => entry.function.name === name) + expect(tool, `missing tool: ${name}`).toBeDefined() + expect(tool?.function.parameters.additionalProperties).toBe(false) + } + }) + + it('keeps mcp_call_tool parameters items strict', async () => { + const tools = await mcp() + const callTool = tools.find(entry => entry.function.name === 'mcp_call_tool') + + expect(callTool).toBeDefined() + const items = ((callTool?.function.parameters as JsonSchema).properties?.parameters as any)?.items + expect(items).toBeDefined() + expect(items?.additionalProperties).toBe(false) + }) +}) diff --git a/packages/stage-ui/src/tools/mcp.ts b/packages/stage-ui/src/tools/mcp.ts index 5c12180a1..9cf36fa3d 100644 --- a/packages/stage-ui/src/tools/mcp.ts +++ b/packages/stage-ui/src/tools/mcp.ts @@ -9,7 +9,7 @@ const tools = [ execute: async (_, __) => { return await listTools() }, - parameters: z.object({}), + parameters: z.object({}).strict(), }), tool({ name: 'mcp_connect_server', @@ -21,7 +21,7 @@ const tools = [ parameters: z.object({ command: z.string().describe('The command to connect to the MCP server'), args: z.array(z.string()).describe('The arguments to pass to the MCP server'), - }), + }).strict(), }), tool({ name: 'mcp_disconnect_server', @@ -30,7 +30,7 @@ const tools = [ await disconnectServer() return 'success' }, - parameters: z.object({}), + parameters: z.object({}).strict(), }), tool({ name: 'mcp_call_tool', @@ -50,8 +50,8 @@ const tools = [ name: z.string().describe('The name of the tool to call'), parameters: z.array(z.object({ name: z.string().describe('The name of the parameter'), - value: z.union([z.string(), z.number(), z.boolean(), z.object({})]).describe('The value of the parameter, it can be a string, a number, a boolean, or an object'), - })).describe('The parameters to pass to the tool'), + value: z.union([z.string(), z.number(), z.boolean(), z.object({}).strict()]).describe('The value of the parameter, it can be a string, a number, a boolean, or an object'), + }).strict()).describe('The parameters to pass to the tool'), }), }), ]